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.
This commit is contained in:
parent
bca7eff0cd
commit
e7937e5861
212
electron/main.js
212
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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
204
src/App.tsx
204
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<string | null>(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<string, any> }
|
||||
const tab = t as TableTab & { pendingChanges: Map<string, any>; 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}
|
||||
/>
|
||||
<MainContent
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
activeConnection={activeConnection}
|
||||
selectedDatabase={selectedDatabase}
|
||||
connections={connections}
|
||||
connectedIds={connectedIds}
|
||||
databasesMap={databasesMap}
|
||||
databases={databasesMap.get(activeConnection || '') || []}
|
||||
tables={tablesMap.get(selectedDatabase || '') || []}
|
||||
columns={columnsMap}
|
||||
@ -582,6 +650,8 @@ function App() {
|
||||
onAddTableRow={handleAddTableRow}
|
||||
onUpdateNewRow={handleUpdateNewRow}
|
||||
onDeleteNewRow={handleDeleteNewRow}
|
||||
onSelectConnection={handleConnectionChange}
|
||||
onSelectDatabase={handleSelectDatabase}
|
||||
loadingTables={loadingTables}
|
||||
/>
|
||||
</div>
|
||||
@ -634,6 +704,74 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{showTableDesigner && tableDesignerContext && (
|
||||
<TableDesigner
|
||||
isOpen={showTableDesigner}
|
||||
mode={tableDesignerContext.mode}
|
||||
database={tableDesignerContext.database}
|
||||
tableName={tableDesignerContext.tableName}
|
||||
connectionId={tableDesignerContext.connectionId}
|
||||
dbType={connections.find(c => 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 && (
|
||||
<InputDialog
|
||||
isOpen={inputDialog.isOpen}
|
||||
|
||||
@ -137,7 +137,7 @@ export default function ConnectionModal({ isOpen, editingConnection, initialType
|
||||
const isEditing = !!editingConnection
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 animate-fade-in backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 animate-fade-in backdrop-blur-sm">
|
||||
<div className="w-[520px] max-h-[90vh] bg-white flex flex-col overflow-hidden rounded-2xl shadow-modal animate-scale-in" onClick={e => e.stopPropagation()}>
|
||||
{/* 标题 */}
|
||||
<div className="h-14 flex items-center justify-between px-5 border-b border-border-default">
|
||||
|
||||
@ -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<string>
|
||||
databasesMap: Map<string, string[]>
|
||||
databases: string[]
|
||||
tables: TableInfo[]
|
||||
columns: Map<string, ColumnInfo[]>
|
||||
@ -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<string>
|
||||
}
|
||||
|
||||
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({
|
||||
) : (
|
||||
<QueryEditor
|
||||
tab={currentTab}
|
||||
connectionId={activeConnection}
|
||||
selectedDatabase={selectedDatabase}
|
||||
connections={connections}
|
||||
connectedIds={connectedIds}
|
||||
databasesMap={databasesMap}
|
||||
databases={databases}
|
||||
tables={tables}
|
||||
columns={columns}
|
||||
onRun={(sql) => 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({
|
||||
<button
|
||||
onClick={() => onLoadPage(tab.page - 1)}
|
||||
disabled={tab.page <= 1 || isLoading}
|
||||
className="p-1 hover:bg-light-hover disabled:opacity-30 rounded transition-colors"
|
||||
className="p-1 hover:bg-light-hover disabled:opacity-30 rounded transition-colors text-text-primary"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="text-xs min-w-[70px] text-center">
|
||||
<span className="text-xs min-w-[70px] text-center text-text-primary">
|
||||
<span className="text-primary-600 font-medium">{tab.page}</span> / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onLoadPage(tab.page + 1)}
|
||||
disabled={tab.page >= totalPages || isLoading}
|
||||
className="p-1 hover:bg-light-hover disabled:opacity-30 rounded transition-colors"
|
||||
className="p-1 hover:bg-light-hover disabled:opacity-30 rounded transition-colors text-text-primary"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
@ -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"
|
||||
>
|
||||
<option value={100}>100</option>
|
||||
<option value={500}>500</option>
|
||||
@ -388,16 +410,31 @@ const TableViewer = memo(function TableViewer({
|
||||
|
||||
{/* 表格 */}
|
||||
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
{isLoading && (
|
||||
<div className="loading-overlay">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 size={28} className="animate-spin text-primary-500" />
|
||||
<span className="text-sm text-text-secondary">加载数据中...</span>
|
||||
{isLoading && tab.columns.length === 0 ? (
|
||||
// 初始加载时显示全屏 loading
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-light-bg">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-primary-500" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium text-text-primary mb-1">正在加载表数据</div>
|
||||
<div className="text-xs text-text-muted">{tab.tableName}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<VirtualDataTable
|
||||
) : (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="loading-overlay">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 size={28} className="animate-spin text-primary-500" />
|
||||
<span className="text-sm text-text-secondary">加载数据中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<VirtualDataTable
|
||||
columns={tab.columns}
|
||||
data={visibleData}
|
||||
showColumnInfo={true}
|
||||
@ -442,7 +479,9 @@ const TableViewer = memo(function TableViewer({
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
@ -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<string>
|
||||
databasesMap: Map<string, string[]>
|
||||
databases: string[]
|
||||
tables: TableInfo[]
|
||||
columns: Map<string, ColumnInfo[]>
|
||||
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<string | null>(null)
|
||||
const [showExportMenu, setShowExportMenu] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// 本地数据状态(用于编辑)
|
||||
const [localData, setLocalData] = useState<any[]>([])
|
||||
const [originalData, setOriginalData] = useState<any[]>([]) // 保存原始数据用于对比
|
||||
const [modifiedCells, setModifiedCells] = useState<Set<string>>(new Set())
|
||||
const [deletedRows, setDeletedRows] = useState<Set<number>>(new Set()) // 待删除的行索引(原始数据的索引)
|
||||
|
||||
// 当查询结果变化时,更新本地数据
|
||||
useEffect(() => {
|
||||
if (tab.results) {
|
||||
const data = tab.results.rows.map(row => {
|
||||
const obj: Record<string, any> = {}
|
||||
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<number, Record<string, any>>()
|
||||
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<string, any> = {}
|
||||
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<string>()
|
||||
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<string>()
|
||||
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({
|
||||
{/* 工具栏 */}
|
||||
<div style={{ height: '200px', flexShrink: 0, display: 'flex', flexDirection: 'column', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div className="h-11 bg-light-surface flex items-center px-3 gap-2" style={{ flexShrink: 0 }}>
|
||||
{/* 连接选择器 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowConnectionMenu(!showConnectionMenu)}
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-2 text-sm text-text-primary rounded-lg transition-colors min-w-[140px]"
|
||||
>
|
||||
<Server size={14} className="text-primary-500" />
|
||||
<span className="truncate max-w-[100px]">{currentConnection?.name || '选择连接'}</span>
|
||||
<ChevronDown size={14} className="text-text-muted ml-auto" />
|
||||
</button>
|
||||
{showConnectionMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowConnectionMenu(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border border-border-default rounded-lg shadow-lg z-50 min-w-[180px] py-1 max-h-[300px] overflow-auto">
|
||||
{connections.filter(c => connectedIds.has(c.id)).length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-text-muted">暂无已连接的数据库</div>
|
||||
) : (
|
||||
connections.filter(c => connectedIds.has(c.id)).map(conn => (
|
||||
<button
|
||||
key={conn.id}
|
||||
onClick={() => {
|
||||
onSelectConnection?.(conn.id)
|
||||
setShowConnectionMenu(false)
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-2 ${conn.id === connectionId ? 'bg-primary-50 text-primary-600' : 'text-text-primary'}`}
|
||||
>
|
||||
<Server size={14} className={conn.id === connectionId ? 'text-primary-500' : 'text-text-muted'} />
|
||||
<span className="truncate">{conn.name}</span>
|
||||
{conn.id === connectionId && <Check size={14} className="ml-auto text-primary-500" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 数据库选择器 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => connectionId && setShowDatabaseMenu(!showDatabaseMenu)}
|
||||
disabled={!connectionId}
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-2 text-sm text-text-primary rounded-lg transition-colors min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Database size={14} className="text-teal-500" />
|
||||
<span className="truncate max-w-[100px]">{selectedDatabase || '选择数据库'}</span>
|
||||
<ChevronDown size={14} className="text-text-muted ml-auto" />
|
||||
</button>
|
||||
{showDatabaseMenu && connectionId && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowDatabaseMenu(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border border-border-default rounded-lg shadow-lg z-50 min-w-[180px] py-1 max-h-[300px] overflow-auto">
|
||||
{currentDatabases.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-text-muted">暂无数据库</div>
|
||||
) : (
|
||||
currentDatabases.map(db => (
|
||||
<button
|
||||
key={db}
|
||||
onClick={() => {
|
||||
onSelectDatabase?.(db, connectionId)
|
||||
setShowDatabaseMenu(false)
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-2 ${db === selectedDatabase ? 'bg-primary-50 text-primary-600' : 'text-text-primary'}`}
|
||||
>
|
||||
<Database size={14} className={db === selectedDatabase ? 'text-teal-500' : 'text-text-muted'} />
|
||||
<span className="truncate">{db}</span>
|
||||
{db === selectedDatabase && <Check size={14} className="ml-auto text-primary-500" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-border-default mx-1" />
|
||||
|
||||
<button onClick={handleRun}
|
||||
className="h-8 px-4 bg-success-500 hover:bg-success-600 text-white flex items-center gap-1.5 text-sm font-medium rounded-lg shadow-sm transition-all">
|
||||
<Play size={13} fill="currentColor" />
|
||||
@ -586,24 +978,24 @@ const QueryEditor = memo(function QueryEditor({
|
||||
</button>
|
||||
<div className="w-px h-5 bg-border-default mx-1" />
|
||||
<button onClick={handleOpenFile}
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-1.5 text-sm rounded-lg transition-colors">
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-1.5 text-sm text-text-primary rounded-lg transition-colors">
|
||||
<FolderOpen size={14} />
|
||||
打开
|
||||
</button>
|
||||
<button onClick={handleSaveFile}
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-1.5 text-sm rounded-lg transition-colors">
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-1.5 text-sm text-text-primary rounded-lg transition-colors">
|
||||
<Save size={14} />
|
||||
保存
|
||||
</button>
|
||||
<button onClick={handleFormat}
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-1.5 text-sm rounded-lg transition-colors">
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-1.5 text-sm text-text-primary rounded-lg transition-colors">
|
||||
<AlignLeft size={14} />
|
||||
格式化
|
||||
</button>
|
||||
<div className="w-px h-5 bg-border-default mx-1" />
|
||||
<div className="relative">
|
||||
<button onClick={() => setShowExportMenu(!showExportMenu)} disabled={!tab.results || tab.results.rows.length === 0}
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-1.5 text-sm rounded-lg transition-colors disabled:opacity-40">
|
||||
className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-1.5 text-sm text-text-primary rounded-lg transition-colors disabled:opacity-40">
|
||||
<Download size={14} />
|
||||
导出
|
||||
</button>
|
||||
@ -612,11 +1004,11 @@ const QueryEditor = memo(function QueryEditor({
|
||||
<div className="fixed inset-0" onClick={() => setShowExportMenu(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border border-border-default rounded-lg shadow-lg z-50 min-w-[120px] py-1 menu">
|
||||
<button onClick={() => { handleExportCsv(); setShowExportMenu(false) }}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-2">
|
||||
className="w-full px-3 py-2 text-left text-sm text-text-primary hover:bg-light-hover flex items-center gap-2">
|
||||
<FileSpreadsheet size={14} className="text-success-500" /> CSV
|
||||
</button>
|
||||
<button onClick={() => { handleExportSql(); setShowExportMenu(false) }}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-2">
|
||||
className="w-full px-3 py-2 text-left text-sm text-text-primary hover:bg-light-hover flex items-center gap-2">
|
||||
<FileCode size={14} className="text-warning-500" /> SQL
|
||||
</button>
|
||||
</div>
|
||||
@ -642,13 +1034,29 @@ const QueryEditor = memo(function QueryEditor({
|
||||
<span className="text-sm text-text-secondary flex items-center gap-2">
|
||||
<Database size={14} className="text-primary-500" />
|
||||
结果
|
||||
{tab.results && <span className="text-text-muted text-xs ml-2">({tab.results.rows.length.toLocaleString()} 行)</span>}
|
||||
{tab.results && (
|
||||
<span className="text-text-muted text-xs ml-2">
|
||||
({localData.length.toLocaleString()} 行)
|
||||
{modifiedCells.size > 0 && <span className="text-warning-500 ml-2">· {modifiedCells.size} 已修改</span>}
|
||||
{deletedRows.size > 0 && <span className="text-danger-500 ml-2">· {deletedRows.size} 待删除</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: tab.results ? 32 : 0 }}>
|
||||
{tab.results ? (
|
||||
<VirtualDataTable columns={resultColumns} data={resultData} showColumnInfo={true} onRefresh={() => onRun(sql)} />
|
||||
<VirtualDataTable
|
||||
columns={resultColumns}
|
||||
data={localData}
|
||||
showColumnInfo={true}
|
||||
editable={true}
|
||||
onRefresh={() => onRun(sql)}
|
||||
onCellChange={handleCellChange}
|
||||
onDeleteRow={handleDeleteRow}
|
||||
onDeleteRows={handleDeleteRows}
|
||||
modifiedCells={modifiedCells}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
@ -660,6 +1068,50 @@ const QueryEditor = memo(function QueryEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部工具栏 */}
|
||||
{tab.results && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-8 bg-light-surface border-t border-border-default flex items-center px-3 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleSaveChanges}
|
||||
disabled={(modifiedCells.size === 0 && deletedRows.size === 0) || isSaving || !connectionId}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${(modifiedCells.size > 0 || deletedRows.size > 0) && connectionId ? 'hover:bg-success-50 text-success-500' : 'text-text-disabled'}`}
|
||||
title={connectionId ? "保存修改到数据库" : "未连接数据库"}
|
||||
>
|
||||
{isSaving ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 恢复原始数据
|
||||
setLocalData(JSON.parse(JSON.stringify(originalData)))
|
||||
setModifiedCells(new Set())
|
||||
setDeletedRows(new Set())
|
||||
}}
|
||||
disabled={(modifiedCells.size === 0 && deletedRows.size === 0) || isSaving}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${(modifiedCells.size > 0 || deletedRows.size > 0) ? 'hover:bg-danger-50 text-danger-500' : 'text-text-disabled'}`}
|
||||
title="放弃修改"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRun(sql)}
|
||||
disabled={isSaving}
|
||||
className="w-7 h-7 flex items-center justify-center hover:bg-light-hover rounded text-text-tertiary disabled:opacity-40"
|
||||
title="刷新数据 (重新执行查询)"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 text-center text-xs text-text-muted">
|
||||
{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} 行`}
|
||||
</div>
|
||||
<div className="text-xs text-text-disabled font-mono truncate max-w-[300px]" title={sql}>
|
||||
{sql.length > 50 ? sql.substring(0, 50) + '...' : sql}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -69,16 +69,17 @@ const TableGroupList = memo(function TableGroupList({
|
||||
</span>
|
||||
</div>
|
||||
{isTablesExpanded && (
|
||||
<div className="ml-5 pl-3 border-l border-border-light">
|
||||
<div className="ml-4 pl-2 border-l border-border-light">
|
||||
{regularTables.map(table => (
|
||||
<div
|
||||
key={table.name}
|
||||
className="flex items-center gap-2 px-3 py-1.5 mx-1 text-xs text-text-secondary hover:bg-light-hover cursor-pointer transition-colors rounded-lg group"
|
||||
className="flex items-center gap-2 px-2 py-1.5 mr-1 text-text-secondary hover:bg-light-hover cursor-pointer transition-colors rounded-lg group"
|
||||
onClick={() => onOpenTable(connectionId, db, table.name)}
|
||||
onContextMenu={(e) => onContextMenu(e, table.name)}
|
||||
title={table.name}
|
||||
>
|
||||
<Table2 size={12} className="text-warning-500 flex-shrink-0" />
|
||||
<span className="truncate font-mono text-[11px]">{table.name}</span>
|
||||
<Table2 size={14} className="text-warning-500 flex-shrink-0" />
|
||||
<span className="truncate font-mono text-[13px] flex-1 min-w-0">{table.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -105,16 +106,17 @@ const TableGroupList = memo(function TableGroupList({
|
||||
</span>
|
||||
</div>
|
||||
{isViewsExpanded && (
|
||||
<div className="ml-5 pl-3 border-l border-border-light">
|
||||
<div className="ml-4 pl-2 border-l border-border-light">
|
||||
{views.map(view => (
|
||||
<div
|
||||
key={view.name}
|
||||
className="flex items-center gap-2 px-3 py-1.5 mx-1 text-xs text-text-secondary hover:bg-light-hover cursor-pointer transition-colors rounded-lg group"
|
||||
className="flex items-center gap-2 px-2 py-1.5 mr-1 text-text-secondary hover:bg-light-hover cursor-pointer transition-colors rounded-lg group"
|
||||
onClick={() => onOpenTable(connectionId, db, view.name)}
|
||||
onContextMenu={(e) => onContextMenu(e, view.name)}
|
||||
title={view.name}
|
||||
>
|
||||
<Eye size={12} className="text-info-500 flex-shrink-0" />
|
||||
<span className="truncate font-mono text-[11px]">{view.name}</span>
|
||||
<Eye size={14} className="text-info-500 flex-shrink-0" />
|
||||
<span className="truncate font-mono text-[13px] flex-1 min-w-0">{view.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -133,6 +135,7 @@ interface Props {
|
||||
tablesMap: Map<string, TableInfo[]>
|
||||
selectedDatabase: string | null
|
||||
loadingDbSet: Set<string>
|
||||
loadingConnectionsSet?: Set<string>
|
||||
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<HTMLInputElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const prevConnectedIdsRef = useRef<Set<string>>(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({
|
||||
<>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="w-64 bg-light-surface flex flex-col h-full select-none border-r border-border-default"
|
||||
className="w-80 bg-light-surface flex flex-col h-full select-none border-r border-border-default"
|
||||
tabIndex={0}
|
||||
onFocus={() => 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({
|
||||
</div>
|
||||
|
||||
{/* 数据库列表 */}
|
||||
{showDatabases && (
|
||||
{isExpanded && isConnected && (
|
||||
<div className="ml-5 mt-0.5 pl-3 border-l border-border-light animate-slide-down">
|
||||
{getFilteredDatabases(connDatabases).map(db => {
|
||||
{loadingConnectionsSet?.has(conn.id) ? (
|
||||
<div className="px-2.5 py-2 text-sm text-text-muted flex items-center gap-2">
|
||||
<span className="w-3 h-3 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
|
||||
加载数据库...
|
||||
</div>
|
||||
) : connDatabases.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-sm text-text-muted">
|
||||
无数据库或无权限
|
||||
</div>
|
||||
) : 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({
|
||||
) : (
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onConnect(menu.conn); setMenu(null) }}
|
||||
onClick={() => {
|
||||
onConnect(menu.conn)
|
||||
setExpandedDbs(prev => new Set(prev).add(menu.conn.id))
|
||||
setMenu(null)
|
||||
}}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full border-2 border-success-500" />
|
||||
连接
|
||||
|
||||
@ -716,7 +716,7 @@ export default function SqlEditor({ value, onChange, onRun, onSave, onOpen, onFo
|
||||
value={value}
|
||||
onChange={(v) => onChange(v || '')}
|
||||
onMount={handleEditorMount}
|
||||
theme="vs-dark"
|
||||
theme="vs"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
X, Table2, Plus, Trash2, Key, ArrowUp, ArrowDown, Save,
|
||||
FileCode, Settings, Link2, List, Database, Play, Eye, GripVertical
|
||||
FileCode, Settings, Link2, List, Database, Play, Eye, GripVertical,
|
||||
Check, Search, ChevronDown
|
||||
} from 'lucide-react'
|
||||
|
||||
// ============ 类型定义 ============
|
||||
@ -164,30 +165,44 @@ function SearchableSelect({ value, options, onChange, placeholder = '选择...',
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative flex items-center justify-between cursor-pointer ${className}`}
|
||||
className={`relative flex items-center justify-between cursor-pointer rounded-lg transition-all duration-200 ${className} ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-light-hover'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) setIsOpen(!isOpen)
|
||||
}}
|
||||
>
|
||||
<span className={`text-xs ${selectedOption ? 'text-text-primary' : 'text-text-secondary'} ${disabled ? 'opacity-50' : ''}`}>
|
||||
<span className={`text-sm font-medium ${selectedOption ? 'text-text-primary' : 'text-text-muted'}`}>
|
||||
{selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<span className="text-text-secondary text-[10px]">▼</span>
|
||||
<ChevronDown size={14} className={`text-text-tertiary ml-2 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 top-full left-0 w-full min-w-[120px] mt-0.5 bg-metro-surface border border-metro-border shadow-lg max-h-48 overflow-hidden flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="搜索..."
|
||||
className="w-full h-7 px-2 bg-metro-hover border-b border-metro-border text-xs focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="overflow-auto flex-1">
|
||||
<div className="absolute z-50 top-full left-0 w-full min-w-[160px] mt-1.5 bg-white border border-border-default
|
||||
shadow-xl rounded-xl overflow-hidden flex flex-col animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
style={{ maxHeight: '280px' }}>
|
||||
{/* 搜索框 */}
|
||||
<div className="p-2.5 border-b border-border-light bg-gradient-to-b from-light-surface to-white">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="搜索..."
|
||||
className="w-full h-9 pl-9 pr-3 bg-white border border-border-default rounded-lg text-sm text-text-primary
|
||||
focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-100 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选项列表 */}
|
||||
<div className="overflow-auto flex-1 py-1.5" style={{ maxHeight: '220px' }}>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-text-secondary">无匹配项</div>
|
||||
<div className="px-4 py-6 text-sm text-text-muted text-center">
|
||||
<div className="text-2xl mb-2">🔍</div>
|
||||
无匹配项
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map(opt => (
|
||||
<div
|
||||
@ -198,10 +213,15 @@ function SearchableSelect({ value, options, onChange, placeholder = '选择...',
|
||||
setIsOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-metro-hover
|
||||
${opt.value === value ? 'bg-accent-blue/20 text-accent-blue' : ''}`}
|
||||
className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-2.5 mx-1.5 rounded-lg transition-all duration-150
|
||||
${opt.value === value
|
||||
? 'bg-primary-50 text-primary-700 font-medium'
|
||||
: 'text-text-primary hover:bg-light-hover'}`}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === value && (
|
||||
<Check size={14} className="text-primary-500" strokeWidth={2.5} />
|
||||
)}
|
||||
<span className={opt.value === value ? '' : 'ml-5'}>{opt.label}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@ -224,12 +244,20 @@ interface MultiSelectProps {
|
||||
function MultiSelect({ values, options, onChange, placeholder = '选择...', className = '' }: MultiSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [tempValues, setTempValues] = useState<string[]>(values)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredOptions = options.filter(opt =>
|
||||
opt.label.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
// 打开时同步当前值
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTempValues(values)
|
||||
}
|
||||
}, [isOpen, values])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
@ -242,13 +270,33 @@ function MultiSelect({ values, options, onChange, placeholder = '选择...', cla
|
||||
}, [])
|
||||
|
||||
const toggleValue = (val: string) => {
|
||||
if (values.includes(val)) {
|
||||
onChange(values.filter(v => v !== val))
|
||||
if (tempValues.includes(val)) {
|
||||
setTempValues(tempValues.filter(v => v !== val))
|
||||
} else {
|
||||
onChange([...values, val])
|
||||
setTempValues([...tempValues, val])
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onChange(tempValues)
|
||||
setIsOpen(false)
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setTempValues(values)
|
||||
setIsOpen(false)
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setTempValues(filteredOptions.map(opt => opt.value))
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
setTempValues([])
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
<div
|
||||
@ -256,33 +304,70 @@ function MultiSelect({ values, options, onChange, placeholder = '选择...', cla
|
||||
e.stopPropagation()
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
className="w-full min-h-[28px] px-2 py-1 bg-transparent border border-transparent hover:border-metro-border
|
||||
text-xs flex items-center gap-1 flex-wrap cursor-pointer"
|
||||
className="w-full min-h-[36px] px-3 py-1.5 bg-white/50 border border-border-light hover:border-primary-300 rounded-lg
|
||||
text-sm flex items-center gap-1.5 flex-wrap cursor-pointer transition-all duration-200
|
||||
hover:shadow-sm hover:bg-white"
|
||||
>
|
||||
{values.length === 0 ? (
|
||||
<span className="text-text-secondary">{placeholder}</span>
|
||||
<span className="text-text-muted">{placeholder}</span>
|
||||
) : (
|
||||
values.map(v => (
|
||||
<span key={v} className="bg-accent-blue/20 text-accent-blue px-1.5 py-0.5 rounded text-[10px]">
|
||||
<span key={v} className="bg-gradient-to-r from-primary-100 to-primary-50 text-primary-700 px-2.5 py-1 rounded-md text-xs font-medium
|
||||
border border-primary-200/50 shadow-sm">
|
||||
{v}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
<ChevronDown size={14} className={`ml-auto text-text-tertiary transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 top-full left-0 w-full min-w-[150px] mt-0.5 bg-metro-surface border border-metro-border shadow-lg max-h-48 overflow-hidden flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="搜索..."
|
||||
className="w-full h-7 px-2 bg-metro-hover border-b border-metro-border text-xs focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="overflow-auto flex-1">
|
||||
<div className="absolute z-50 top-full left-0 w-full min-w-[200px] mt-1.5 bg-white border border-border-default
|
||||
shadow-xl rounded-xl overflow-hidden flex flex-col animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
style={{ maxHeight: '320px' }}>
|
||||
{/* 搜索框 */}
|
||||
<div className="p-2.5 border-b border-border-light bg-gradient-to-b from-light-surface to-white">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="搜索字段..."
|
||||
className="w-full h-9 pl-9 pr-3 bg-white border border-border-default rounded-lg text-sm text-text-primary
|
||||
focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-100 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<div className="px-2.5 py-2 border-b border-border-light flex items-center gap-2 bg-light-surface/50">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSelectAll() }}
|
||||
className="text-xs text-primary-600 hover:text-primary-700 font-medium px-2 py-1 rounded hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<span className="text-border-default">|</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleClearAll() }}
|
||||
className="text-xs text-text-tertiary hover:text-text-secondary font-medium px-2 py-1 rounded hover:bg-light-hover transition-colors"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
已选 <span className="text-primary-600 font-medium">{tempValues.length}</span> 项
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 选项列表 */}
|
||||
<div className="overflow-auto flex-1 py-1.5" style={{ maxHeight: '180px' }}>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-text-secondary">无匹配项</div>
|
||||
<div className="px-4 py-6 text-sm text-text-muted text-center">
|
||||
<div className="text-2xl mb-2">🔍</div>
|
||||
无匹配字段
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map(opt => (
|
||||
<div
|
||||
@ -291,20 +376,42 @@ function MultiSelect({ values, options, onChange, placeholder = '选择...', cla
|
||||
e.stopPropagation()
|
||||
toggleValue(opt.value)
|
||||
}}
|
||||
className={`px-2 py-1.5 text-xs cursor-pointer hover:bg-metro-hover flex items-center gap-2
|
||||
${values.includes(opt.value) ? 'bg-accent-blue/10' : ''}`}
|
||||
className={`px-3 py-2 text-sm cursor-pointer flex items-center gap-3 mx-1.5 rounded-lg transition-all duration-150
|
||||
${tempValues.includes(opt.value)
|
||||
? 'bg-primary-50 text-primary-700 hover:bg-primary-100'
|
||||
: 'text-text-primary hover:bg-light-hover'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values.includes(opt.value)}
|
||||
onChange={() => {}}
|
||||
className="w-3 h-3 accent-accent-blue"
|
||||
/>
|
||||
{opt.label}
|
||||
<div className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all duration-150
|
||||
${tempValues.includes(opt.value)
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'border-border-default bg-white'}`}>
|
||||
{tempValues.includes(opt.value) && (
|
||||
<Check size={12} className="text-white" strokeWidth={3} />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
<div className="px-3 py-2.5 border-t border-border-light bg-gradient-to-t from-light-surface to-white flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCancel() }}
|
||||
className="px-4 py-1.5 text-sm text-text-secondary hover:text-text-primary font-medium rounded-lg
|
||||
hover:bg-light-hover transition-all duration-150"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleConfirm() }}
|
||||
className="px-4 py-1.5 text-sm text-white font-medium rounded-lg bg-gradient-to-r from-primary-500 to-primary-600
|
||||
hover:from-primary-600 hover:to-primary-700 shadow-sm hover:shadow-md transition-all duration-150"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -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<string, any>, 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="bg-metro-card border border-metro-border w-[1100px] h-[700px] flex flex-col shadow-metro-lg animate-fade-in">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white border border-border-default w-[1100px] h-[700px] flex flex-col shadow-modal rounded-xl animate-fade-in">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-metro-border bg-metro-surface flex-shrink-0">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border-default bg-white flex-shrink-0 rounded-t-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Table2 size={18} className="text-accent-teal" />
|
||||
<span className="font-medium">
|
||||
<div className="w-8 h-8 rounded-lg bg-teal-50 flex items-center justify-center">
|
||||
<Table2 size={18} className="text-teal-500" />
|
||||
</div>
|
||||
<span className="font-semibold text-text-primary">
|
||||
{mode === 'create' ? '新建表' : '编辑表'} - {database}
|
||||
</span>
|
||||
{mode === 'edit' && initialTableName && (
|
||||
<span className="text-text-secondary">({initialTableName})</span>
|
||||
<span className="text-text-secondary font-normal">({initialTableName})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-accent-blue hover:bg-accent-blue-hover
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-primary-500 hover:bg-primary-600 text-white rounded-lg
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save size={14} />
|
||||
@ -967,17 +1082,17 @@ export default function TableDesigner({
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-metro-hover rounded-sm transition-colors"
|
||||
className="p-2 hover:bg-light-hover rounded-lg transition-colors text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<X size={16} />
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表名 & 注释 & 标签页 */}
|
||||
<div className="border-b border-metro-border bg-metro-surface/50 flex-shrink-0">
|
||||
<div className="border-b border-border-default bg-white/50 flex-shrink-0">
|
||||
{/* 表名和注释 */}
|
||||
<div className="flex items-center gap-6 px-4 py-2 border-b border-metro-border/50">
|
||||
<div className="flex items-center gap-6 px-4 py-2 border-b border-border-default/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-secondary w-12">表名:</span>
|
||||
<input
|
||||
@ -986,8 +1101,8 @@ export default function TableDesigner({
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 标签页 */}
|
||||
<div className="flex px-4">
|
||||
<div className="flex px-5">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm border-b-2 transition-colors
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm border-b-2 transition-colors font-medium
|
||||
${activeTab === tab.id
|
||||
? 'border-accent-blue text-accent-blue'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}`}
|
||||
>
|
||||
<tab.icon size={14} />
|
||||
<tab.icon size={15} />
|
||||
{tab.label}
|
||||
{'count' in tab && tab.count !== undefined && (
|
||||
<span className="ml-1 text-xs bg-metro-hover px-1.5 rounded">{tab.count}</span>
|
||||
<span className={`ml-1 text-xs px-1.5 py-0.5 rounded-full ${activeTab === tab.id ? 'bg-primary-100 text-primary-600' : 'bg-light-muted text-text-secondary'}`}>{tab.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
@ -1026,13 +1141,13 @@ export default function TableDesigner({
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-accent-red/20 border-b border-accent-red/30 text-sm text-accent-red flex-shrink-0">
|
||||
<div className="px-4 py-2 bg-danger-500/20 border-b border-danger-500/30 text-sm text-danger-500 flex-shrink-0">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden bg-light-bg">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-text-secondary">
|
||||
加载中...
|
||||
@ -1167,10 +1282,10 @@ function ColumnsTab({
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-metro-border bg-metro-surface/30 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-default bg-white/30 flex-shrink-0">
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-accent-green hover:bg-accent-green/80 transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-success-500 hover:bg-success-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
添加字段
|
||||
@ -1180,8 +1295,8 @@ function ColumnsTab({
|
||||
{/* 表格 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-metro-surface sticky top-0">
|
||||
<tr className="border-b border-metro-border">
|
||||
<thead className="bg-white sticky top-0">
|
||||
<tr className="border-b border-border-default text-text-primary">
|
||||
<th className="w-8 px-1 py-2"></th>
|
||||
<th className="w-36 px-3 py-2 text-left font-medium">名称</th>
|
||||
<th className="w-28 px-3 py-2 text-left font-medium">类型</th>
|
||||
@ -1201,10 +1316,10 @@ function ColumnsTab({
|
||||
key={col.id}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onClick={() => onSelect(col.id)}
|
||||
className={`border-b border-metro-border/50 cursor-pointer transition-colors
|
||||
${selectedId === col.id ? 'bg-accent-blue/20' : 'hover:bg-metro-hover/50'}
|
||||
${col._isNew ? 'bg-accent-green/10' : ''}
|
||||
${dragOverIndex === index ? 'border-t-2 border-t-accent-blue' : ''}`}
|
||||
className={`border-b border-border-default/50 cursor-pointer transition-colors
|
||||
${selectedId === col.id ? 'bg-primary-500/20' : 'hover:bg-light-hover/50'}
|
||||
${col._isNew ? 'bg-success-50' : ''}
|
||||
${dragOverIndex === index ? 'border-t-2 border-t-primary-500' : ''}`}
|
||||
>
|
||||
<td
|
||||
draggable
|
||||
@ -1221,9 +1336,9 @@ function ColumnsTab({
|
||||
onChange={(e) => onUpdate(col.id, 'name', e.target.value)}
|
||||
onFocus={() => onSelect(col.id)}
|
||||
placeholder="字段名"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-metro-border
|
||||
focus:border-accent-blue focus:bg-metro-surface focus:outline-none text-xs
|
||||
selection:bg-accent-blue selection:text-white"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-border-default
|
||||
focus:border-primary-500 focus:bg-white focus:outline-none text-xs text-text-primary
|
||||
selection:bg-primary-500 selection:text-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
@ -1231,8 +1346,8 @@ function ColumnsTab({
|
||||
value={col.type}
|
||||
onChange={(e) => onUpdate(col.id, 'type', e.target.value)}
|
||||
onFocus={() => onSelect(col.id)}
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-metro-border
|
||||
focus:border-accent-blue focus:bg-metro-surface focus:outline-none text-xs"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-border-default
|
||||
focus:border-primary-500 focus:bg-white focus:outline-none text-xs text-text-primary"
|
||||
>
|
||||
{dataTypes.map(group => (
|
||||
<optgroup key={group.group} label={group.group}>
|
||||
@ -1251,9 +1366,9 @@ function ColumnsTab({
|
||||
onFocus={() => onSelect(col.id)}
|
||||
disabled={!needsLength(col.type)}
|
||||
placeholder={needsLength(col.type) ? '' : '-'}
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-metro-border
|
||||
focus:border-accent-blue focus:bg-metro-surface focus:outline-none text-xs
|
||||
disabled:opacity-40 disabled:cursor-not-allowed selection:bg-accent-blue selection:text-white"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-border-default
|
||||
focus:border-primary-500 focus:bg-white focus:outline-none text-xs text-text-primary
|
||||
disabled:opacity-40 disabled:cursor-not-allowed selection:bg-primary-500 selection:text-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
@ -1264,9 +1379,9 @@ function ColumnsTab({
|
||||
onFocus={() => onSelect(col.id)}
|
||||
disabled={!needsDecimals(col.type)}
|
||||
placeholder={needsDecimals(col.type) ? '' : '-'}
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-metro-border
|
||||
focus:border-accent-blue focus:bg-metro-surface focus:outline-none text-xs
|
||||
disabled:opacity-40 disabled:cursor-not-allowed selection:bg-accent-blue selection:text-white"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-border-default
|
||||
focus:border-primary-500 focus:bg-white focus:outline-none text-xs text-text-primary
|
||||
disabled:opacity-40 disabled:cursor-not-allowed selection:bg-primary-500 selection:text-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
@ -1278,7 +1393,7 @@ function ColumnsTab({
|
||||
onUpdate(col.id, 'nullable', !e.target.checked)
|
||||
}}
|
||||
disabled={col.primaryKey}
|
||||
className="w-4 h-4 accent-accent-blue disabled:opacity-50"
|
||||
className="w-4 h-4 accent-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
</td>
|
||||
{isMysql && (
|
||||
@ -1291,7 +1406,7 @@ function ColumnsTab({
|
||||
onUpdate(col.id, 'unsigned', e.target.checked)
|
||||
}}
|
||||
disabled={!supportsUnsigned(col.type)}
|
||||
className="w-4 h-4 accent-accent-blue disabled:opacity-50"
|
||||
className="w-4 h-4 accent-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
@ -1301,13 +1416,13 @@ function ColumnsTab({
|
||||
onSelect(col.id)
|
||||
onUpdate(col.id, 'primaryKey', !col.primaryKey)
|
||||
}}
|
||||
className={`p-1 rounded-sm transition-colors ${col.primaryKey ? 'bg-accent-orange text-white' : 'hover:bg-metro-hover'}`}
|
||||
className={`p-1.5 rounded transition-colors ${col.primaryKey ? 'bg-warning-500 text-white' : 'text-text-muted hover:bg-light-hover hover:text-warning-500'}`}
|
||||
title={col.primaryKey ? '主键' : '设为主键'}
|
||||
>
|
||||
<Key size={12} />
|
||||
<Key size={14} />
|
||||
</button>
|
||||
{col.autoIncrement && (
|
||||
<span className="ml-1 text-xs text-accent-blue" title="自增">A</span>
|
||||
<span className="ml-1 text-xs text-primary-500" title="自增">A</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
@ -1317,9 +1432,9 @@ function ColumnsTab({
|
||||
onChange={(e) => onUpdate(col.id, 'defaultValue', e.target.value)}
|
||||
onFocus={() => onSelect(col.id)}
|
||||
placeholder=""
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-metro-border
|
||||
focus:border-accent-blue focus:bg-metro-surface focus:outline-none text-xs
|
||||
selection:bg-accent-blue selection:text-white"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-border-default
|
||||
focus:border-primary-500 focus:bg-white focus:outline-none text-xs text-text-primary
|
||||
selection:bg-primary-500 selection:text-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
@ -1329,9 +1444,9 @@ function ColumnsTab({
|
||||
onChange={(e) => onUpdate(col.id, 'comment', e.target.value)}
|
||||
onFocus={() => onSelect(col.id)}
|
||||
placeholder=""
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-metro-border
|
||||
focus:border-accent-blue focus:bg-metro-surface focus:outline-none text-xs
|
||||
selection:bg-accent-blue selection:text-white"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-border-default
|
||||
focus:border-primary-500 focus:bg-white focus:outline-none text-xs text-text-primary
|
||||
selection:bg-primary-500 selection:text-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
@ -1341,7 +1456,7 @@ function ColumnsTab({
|
||||
if (columns.length > 1) onRemove(col.id)
|
||||
}}
|
||||
disabled={columns.length <= 1}
|
||||
className="p-1 text-text-secondary hover:text-accent-red hover:bg-accent-red/10 rounded transition-colors
|
||||
className="p-1 text-text-secondary hover:text-danger-500 hover:bg-danger-500/10 rounded transition-colors
|
||||
disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:text-text-secondary disabled:hover:bg-transparent"
|
||||
title="删除字段"
|
||||
>
|
||||
@ -1373,34 +1488,34 @@ function ColumnDetailPanel({ column, onUpdate, isMysql }: {
|
||||
isMysql: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-metro-border bg-metro-surface/50 px-4 py-3 flex-shrink-0">
|
||||
<div className="border-t border-border-default bg-white px-5 py-3.5 flex-shrink-0">
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-text-primary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={column.autoIncrement}
|
||||
onChange={(e) => onUpdate('autoIncrement', e.target.checked)}
|
||||
className="w-4 h-4 accent-accent-blue"
|
||||
className="w-4 h-4 accent-blue-500 rounded"
|
||||
/>
|
||||
<span>自动递增</span>
|
||||
</label>
|
||||
{isMysql && (
|
||||
<>
|
||||
<label className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-text-primary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={column.zerofill}
|
||||
onChange={(e) => onUpdate('zerofill', e.target.checked)}
|
||||
className="w-4 h-4 accent-accent-blue"
|
||||
className="w-4 h-4 accent-blue-500 rounded"
|
||||
/>
|
||||
<span>填充零</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-text-primary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={column.isVirtual}
|
||||
onChange={(e) => onUpdate('isVirtual', e.target.checked)}
|
||||
className="w-4 h-4 accent-accent-blue"
|
||||
className="w-4 h-4 accent-blue-500 rounded"
|
||||
/>
|
||||
<span>虚拟</span>
|
||||
</label>
|
||||
@ -1439,10 +1554,10 @@ function IndexesTab({ indexes, columns, selectedId, onSelect, onAdd, onRemove, o
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-metro-border bg-metro-surface/30 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-default bg-white/30 flex-shrink-0">
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-accent-green hover:bg-accent-green/80 transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-success-500 hover:bg-success-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
添加索引
|
||||
@ -1452,8 +1567,8 @@ function IndexesTab({ indexes, columns, selectedId, onSelect, onAdd, onRemove, o
|
||||
{/* 表格 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-metro-surface sticky top-0">
|
||||
<tr className="border-b border-metro-border">
|
||||
<thead className="bg-white sticky top-0">
|
||||
<tr className="border-b border-border-default text-text-primary">
|
||||
<th className="w-40 px-3 py-2 text-left font-medium">名称</th>
|
||||
<th className="w-64 px-3 py-2 text-left font-medium">字段</th>
|
||||
<th className="w-28 px-3 py-2 text-left font-medium">索引类型</th>
|
||||
@ -1467,9 +1582,9 @@ function IndexesTab({ indexes, columns, selectedId, onSelect, onAdd, onRemove, o
|
||||
<tr
|
||||
key={idx.id}
|
||||
onClick={() => onSelect(idx.id)}
|
||||
className={`border-b border-metro-border/50 cursor-pointer transition-colors
|
||||
${selectedId === idx.id ? 'bg-accent-blue/20' : 'hover:bg-metro-hover/50'}
|
||||
${idx._isNew ? 'bg-accent-green/10' : ''}`}
|
||||
className={`border-b border-border-default/50 cursor-pointer transition-colors
|
||||
${selectedId === idx.id ? 'bg-primary-500/20' : 'hover:bg-light-hover/50'}
|
||||
${idx._isNew ? 'bg-success-50' : ''}`}
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
<input
|
||||
@ -1478,8 +1593,8 @@ function IndexesTab({ indexes, columns, selectedId, onSelect, onAdd, onRemove, o
|
||||
onChange={(e) => onUpdate(idx.id, 'name', e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="索引名"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-metro-border
|
||||
focus:border-accent-blue focus:bg-metro-surface focus:outline-none text-xs"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-border-default
|
||||
focus:border-primary-500 focus:bg-white focus:outline-none text-xs text-text-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
@ -1515,8 +1630,8 @@ function IndexesTab({ indexes, columns, selectedId, onSelect, onAdd, onRemove, o
|
||||
onChange={(e) => onUpdate(idx.id, 'comment', e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder=""
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-metro-border
|
||||
focus:border-accent-blue focus:bg-metro-surface focus:outline-none text-xs"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-border-default
|
||||
focus:border-primary-500 focus:bg-white focus:outline-none text-xs text-text-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
@ -1525,7 +1640,7 @@ function IndexesTab({ indexes, columns, selectedId, onSelect, onAdd, onRemove, o
|
||||
e.stopPropagation()
|
||||
onRemove(idx.id)
|
||||
}}
|
||||
className="p-1 text-text-secondary hover:text-accent-red hover:bg-accent-red/10 rounded transition-colors"
|
||||
className="p-1 text-text-secondary hover:text-danger-500 hover:bg-danger-500/10 rounded transition-colors"
|
||||
title="删除索引"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@ -1553,7 +1668,7 @@ interface ForeignKeysTabProps {
|
||||
onSelect: (id: string | null) => void
|
||||
onAdd: () => void
|
||||
onRemove: (id: string) => void
|
||||
onUpdate: (id: string, field: keyof ForeignKeyDef, value: any) => void
|
||||
onUpdate: (id: string, field: keyof ForeignKeyDef | Record<string, any>, value?: any) => void
|
||||
onGetDatabases?: () => Promise<string[]>
|
||||
onGetTables?: (database: string) => Promise<string[]>
|
||||
onGetColumns?: (database: string, table: string) => Promise<string[]>
|
||||
@ -1574,6 +1689,18 @@ function ForeignKeysTab({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 当外键列表变化时,为没有加载表列表的外键自动加载当前数据库的表
|
||||
useEffect(() => {
|
||||
foreignKeys.forEach(fk => {
|
||||
if (!refTables[fk.id] && onGetTables) {
|
||||
const schema = fk.refSchema || currentDatabase
|
||||
onGetTables(schema).then(tables => {
|
||||
setRefTables(prev => ({ ...prev, [fk.id]: tables }))
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [foreignKeys.length, currentDatabase])
|
||||
|
||||
const loadRefTables = async (fkId: string, schema: string) => {
|
||||
if (!onGetTables) return
|
||||
const tables = await onGetTables(schema)
|
||||
@ -1594,10 +1721,10 @@ function ForeignKeysTab({
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-metro-border bg-metro-surface/30 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-default bg-white/30 flex-shrink-0">
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-accent-green hover:bg-accent-green/80 transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-success-500 hover:bg-success-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
添加外键
|
||||
@ -1607,11 +1734,11 @@ function ForeignKeysTab({
|
||||
{/* 表格 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-metro-surface sticky top-0">
|
||||
<tr className="border-b border-metro-border">
|
||||
<thead className="bg-white sticky top-0">
|
||||
<tr className="border-b border-border-default text-text-primary">
|
||||
<th className="w-36 px-3 py-2 text-left font-medium">名称</th>
|
||||
<th className="w-28 px-3 py-2 text-left font-medium">字段</th>
|
||||
<th className="w-28 px-3 py-2 text-left font-medium">被引用的模式</th>
|
||||
<th className="w-28 px-3 py-2 text-left font-medium">被引用的数据库</th>
|
||||
<th className="w-28 px-3 py-2 text-left font-medium">被引用的表</th>
|
||||
<th className="w-28 px-3 py-2 text-left font-medium">被引用的字段</th>
|
||||
<th className="w-24 px-3 py-2 text-left font-medium">删除时</th>
|
||||
@ -1624,9 +1751,9 @@ function ForeignKeysTab({
|
||||
<tr
|
||||
key={fk.id}
|
||||
onClick={() => onSelect(fk.id)}
|
||||
className={`border-b border-metro-border/50 cursor-pointer transition-colors
|
||||
${selectedId === fk.id ? 'bg-accent-blue/20' : 'hover:bg-metro-hover/50'}
|
||||
${fk._isNew ? 'bg-accent-green/10' : ''}`}
|
||||
className={`border-b border-border-default/50 cursor-pointer transition-colors
|
||||
${selectedId === fk.id ? 'bg-primary-500/20' : 'hover:bg-light-hover/50'}
|
||||
${fk._isNew ? 'bg-success-50' : ''}`}
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
<input
|
||||
@ -1635,8 +1762,8 @@ function ForeignKeysTab({
|
||||
onChange={(e) => onUpdate(fk.id, 'name', e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="外键名"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-metro-border
|
||||
focus:border-accent-blue focus:bg-metro-surface focus:outline-none text-xs"
|
||||
className="w-full h-7 px-2 bg-transparent border border-transparent hover:border-border-default
|
||||
focus:border-primary-500 focus:bg-white focus:outline-none text-xs text-text-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
@ -1652,10 +1779,12 @@ function ForeignKeysTab({
|
||||
value={fk.refSchema || currentDatabase}
|
||||
options={dbOptions}
|
||||
onChange={(val) => {
|
||||
onUpdate(fk.id, 'refSchema', val)
|
||||
// 批量更新:设置数据库并清空表和字段
|
||||
onUpdate(fk.id, { refSchema: val, refTable: '', refColumns: [] })
|
||||
// 自动加载该数据库下的表列表
|
||||
loadRefTables(fk.id, val)
|
||||
}}
|
||||
placeholder="选择模式"
|
||||
placeholder="选择数据库"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
@ -1666,32 +1795,18 @@ function ForeignKeysTab({
|
||||
onUpdate(fk.id, 'refTable', val)
|
||||
loadRefColumns(fk.id, fk.refSchema || currentDatabase, val)
|
||||
}}
|
||||
placeholder="选择表"
|
||||
placeholder={refTables[fk.id] ? "选择表" : "请先选择数据库"}
|
||||
disabled={!refTables[fk.id]}
|
||||
/>
|
||||
{!refTables[fk.id] && (
|
||||
<button
|
||||
onClick={() => loadRefTables(fk.id, fk.refSchema || currentDatabase)}
|
||||
className="text-[10px] text-accent-blue hover:underline mt-0.5"
|
||||
>
|
||||
加载表列表
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<SearchableSelect
|
||||
value={fk.refColumns[0] || ''}
|
||||
options={(refColumns[fk.id] || []).map(c => ({ label: c, value: c }))}
|
||||
onChange={(val) => onUpdate(fk.id, 'refColumns', [val])}
|
||||
placeholder="选择字段"
|
||||
placeholder={refColumns[fk.id] ? "选择字段" : "请先选择表"}
|
||||
disabled={!fk.refTable || !refColumns[fk.id]}
|
||||
/>
|
||||
{fk.refTable && !refColumns[fk.id] && (
|
||||
<button
|
||||
onClick={() => loadRefColumns(fk.id, fk.refSchema || currentDatabase, fk.refTable)}
|
||||
className="text-[10px] text-accent-blue hover:underline mt-0.5"
|
||||
>
|
||||
加载字段
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<SearchableSelect
|
||||
@ -1713,7 +1828,7 @@ function ForeignKeysTab({
|
||||
e.stopPropagation()
|
||||
onRemove(fk.id)
|
||||
}}
|
||||
className="p-1 text-text-secondary hover:text-accent-red hover:bg-accent-red/10 rounded transition-colors"
|
||||
className="p-1 text-text-secondary hover:text-danger-500 hover:bg-danger-500/10 rounded transition-colors"
|
||||
title="删除外键"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@ -1757,30 +1872,30 @@ function OptionsTab({ options, dbType, onChange }: OptionsTabProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">数据库引擎</label>
|
||||
<label className="block text-sm text-text-primary font-medium mb-2">数据库引擎</label>
|
||||
<SearchableSelect
|
||||
value={options.engine}
|
||||
options={engineOptions}
|
||||
onChange={(val) => onChange({ ...options, engine: val })}
|
||||
placeholder="选择引擎"
|
||||
className="h-9 bg-metro-surface border border-metro-border px-2"
|
||||
className="h-10 bg-white border border-border-default rounded-lg px-3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">行格式</label>
|
||||
<label className="block text-sm text-text-primary font-medium mb-2">行格式</label>
|
||||
<SearchableSelect
|
||||
value={options.rowFormat}
|
||||
options={rowFormatOptions}
|
||||
onChange={(val) => onChange({ ...options, rowFormat: val })}
|
||||
placeholder="选择行格式"
|
||||
className="h-9 bg-metro-surface border border-metro-border px-2"
|
||||
className="h-10 bg-white border border-border-default rounded-lg px-3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">字符集</label>
|
||||
<label className="block text-sm text-text-primary font-medium mb-2">字符集</label>
|
||||
<SearchableSelect
|
||||
value={options.charset}
|
||||
options={charsetOptions}
|
||||
@ -1793,28 +1908,28 @@ function OptionsTab({ options, dbType, onChange }: OptionsTabProps) {
|
||||
})
|
||||
}}
|
||||
placeholder="选择字符集"
|
||||
className="h-9 bg-metro-surface border border-metro-border px-2"
|
||||
className="h-10 bg-white border border-border-default rounded-lg px-3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">排序规则</label>
|
||||
<label className="block text-sm text-text-primary font-medium mb-2">排序规则</label>
|
||||
<SearchableSelect
|
||||
value={options.collation}
|
||||
options={collationOptions}
|
||||
onChange={(val) => onChange({ ...options, collation: val })}
|
||||
placeholder="选择排序规则"
|
||||
className="h-9 bg-metro-surface border border-metro-border px-2"
|
||||
className="h-10 bg-white border border-border-default rounded-lg px-3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">自增值</label>
|
||||
<label className="block text-sm text-text-primary font-medium mb-2">自增值</label>
|
||||
<input
|
||||
type="text"
|
||||
value={options.autoIncrement}
|
||||
onChange={(e) => onChange({ ...options, autoIncrement: e.target.value })}
|
||||
placeholder="默认"
|
||||
className="w-full h-9 px-3 bg-metro-surface border border-metro-border text-sm
|
||||
focus:border-accent-blue focus:outline-none transition-colors"
|
||||
className="w-full h-10 px-3 bg-white border border-border-default text-sm text-text-primary rounded-lg
|
||||
focus:border-primary-500 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -1834,17 +1949,17 @@ function SqlPreviewTab({ sql }: { sql: string }) {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-metro-border bg-metro-surface/30 flex-shrink-0">
|
||||
<span className="text-sm text-text-secondary">将要执行的 SQL 语句</span>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border-default bg-white flex-shrink-0">
|
||||
<span className="text-sm text-text-primary font-medium">将要执行的 SQL 语句</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-metro-surface hover:bg-metro-hover border border-metro-border transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-text-primary bg-white hover:bg-light-hover border border-border-default rounded-lg transition-colors"
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
{copied ? '✓ 已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<pre className="text-sm font-mono text-accent-teal whitespace-pre-wrap break-all">
|
||||
<div className="flex-1 overflow-auto p-5 bg-slate-50">
|
||||
<pre className="text-sm font-mono text-primary-600 whitespace-pre-wrap break-all leading-relaxed">
|
||||
{sql}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
130
src/index.css
130
src/index.css
@ -367,12 +367,58 @@ button:focus:not(:focus-visible) {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.navi-header-cell:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.navi-header-cell.resizing {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
/* 列宽拖动手柄 */
|
||||
.navi-resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.navi-resize-handle:hover,
|
||||
.navi-header-cell.resizing .navi-resize-handle {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.navi-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: #cbd5e1;
|
||||
border-radius: 1px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.navi-header-cell:hover .navi-resize-handle::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navi-header-cell.resizing .navi-resize-handle::after {
|
||||
opacity: 1;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.navi-col-name {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
@ -413,6 +459,21 @@ button:focus:not(:focus-visible) {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
/* 空白填充行 */
|
||||
.navi-row.empty-row {
|
||||
background: #fcfcfd;
|
||||
}
|
||||
|
||||
.navi-row-number.empty {
|
||||
background: #fafafa;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.navi-cell.empty {
|
||||
background: transparent;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* 行号 */
|
||||
.navi-row-number {
|
||||
position: sticky;
|
||||
@ -496,6 +557,71 @@ button:focus:not(:focus-visible) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 日期单元格通用样式 */
|
||||
.navi-date-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.navi-date-icon {
|
||||
flex-shrink: 0;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.navi-date-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #1e293b;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 日期编辑状态 - 与未编辑状态保持一致 */
|
||||
.navi-date-cell-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0 10px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.navi-date-input-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #1e293b;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.navi-date-input-field::placeholder {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
||||
/* 日期时间选择器动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 右键菜单 */
|
||||
.navi-context-menu {
|
||||
position: fixed;
|
||||
@ -648,7 +774,9 @@ button:focus:not(:focus-visible) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navi-header-row {
|
||||
|
||||
@ -54,6 +54,9 @@ declare global {
|
||||
saveDialog: (options: any) => Promise<string | null>
|
||||
writeFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }>
|
||||
readFile: (filePath: string) => Promise<{ success: boolean; content?: string; error?: string }>
|
||||
|
||||
// 密码解密
|
||||
decryptNavicatPassword: (encryptedPassword: string, version?: number) => Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -248,6 +251,24 @@ const api = {
|
||||
return { columns: [], rows: [], error: e.toString() }
|
||||
}
|
||||
},
|
||||
|
||||
// 执行查询(query 的别名,用于兼容)
|
||||
executeQuery: async (id: string, sql: string): Promise<{ columns: string[]; rows: any[]; error?: string }> => {
|
||||
const electronAPI = getElectronAPI()
|
||||
if (!electronAPI) {
|
||||
return { columns: [], rows: [], error: 'Electron API 不可用' }
|
||||
}
|
||||
try {
|
||||
const result = await electronAPI.query(id, sql)
|
||||
return {
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
error: result.error,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { columns: [], rows: [], error: e.toString() }
|
||||
}
|
||||
},
|
||||
|
||||
getDatabases: async (id: string): Promise<string[]> => {
|
||||
const electronAPI = getElectronAPI()
|
||||
@ -282,6 +303,18 @@ const api = {
|
||||
}
|
||||
},
|
||||
|
||||
// 别名,兼容旧代码
|
||||
getTableColumns: async (id: string, database: string, table: string): Promise<ColumnInfo[]> => {
|
||||
const electronAPI = getElectronAPI()
|
||||
if (!electronAPI) return []
|
||||
try {
|
||||
return await electronAPI.getColumns(id, database, table)
|
||||
} catch (e) {
|
||||
console.error('getTableColumns error:', e)
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
getTableData: async (id: string, database: string, table: string, page?: number, pageSize?: number): Promise<{ data: any[]; total: number; columns?: ColumnInfo[] }> => {
|
||||
const electronAPI = getElectronAPI()
|
||||
if (!electronAPI) return { data: [], total: 0 }
|
||||
@ -344,6 +377,44 @@ const api = {
|
||||
}
|
||||
},
|
||||
|
||||
// 更新行(简化参数格式,兼容旧代码)
|
||||
updateTableRow: async (id: string, database: string, tableName: string, primaryKeyColumn: string, primaryKeyValue: any, updates: Record<string, any>): Promise<{ success?: boolean; error?: string }> => {
|
||||
const electronAPI = getElectronAPI()
|
||||
if (!electronAPI) return { success: false, error: 'Electron API 不可用' }
|
||||
try {
|
||||
const result = await electronAPI.updateRow(id, database, tableName, { column: primaryKeyColumn, value: primaryKeyValue }, updates)
|
||||
return { success: result.success, error: result.success ? undefined : result.message }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.toString() }
|
||||
}
|
||||
},
|
||||
|
||||
// 插入行(对象格式,兼容旧代码)
|
||||
insertTableRow: async (id: string, database: string, tableName: string, data: Record<string, any>): Promise<{ success?: boolean; error?: string; insertId?: number }> => {
|
||||
const columns = Object.keys(data)
|
||||
const values = Object.values(data)
|
||||
const electronAPI = getElectronAPI()
|
||||
if (!electronAPI) return { success: false, error: 'Electron API 不可用' }
|
||||
try {
|
||||
const result = await electronAPI.insertRow(id, database, tableName, columns, values)
|
||||
return { success: result.success, error: result.success ? undefined : result.message, insertId: result.insertId }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.toString() }
|
||||
}
|
||||
},
|
||||
|
||||
// 删除行(简化参数格式,兼容旧代码)
|
||||
deleteTableRow: async (id: string, database: string, tableName: string, primaryKeyColumn: string, primaryKeyValue: any): Promise<{ success?: boolean; error?: string }> => {
|
||||
const electronAPI = getElectronAPI()
|
||||
if (!electronAPI) return { success: false, error: 'Electron API 不可用' }
|
||||
try {
|
||||
const result = await electronAPI.deleteRow(id, database, tableName, { column: primaryKeyColumn, value: primaryKeyValue })
|
||||
return { success: result.success, error: result.success ? undefined : result.message }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.toString() }
|
||||
}
|
||||
},
|
||||
|
||||
// 数据库管理
|
||||
createDatabase: async (id: string, dbName: string, charset = 'utf8mb4', collation = 'utf8mb4_general_ci'): Promise<{ success: boolean; message: string }> => {
|
||||
const electronAPI = getElectronAPI()
|
||||
@ -566,7 +637,8 @@ const api = {
|
||||
|
||||
let connections: any[]
|
||||
if (isNcx) {
|
||||
connections = parseNcx(result.content)
|
||||
// NCX 解析现在是异步的,因为需要解密密码
|
||||
connections = await parseNcxAsync(result.content, electronAPI)
|
||||
} else {
|
||||
connections = JSON.parse(result.content)
|
||||
}
|
||||
@ -694,36 +766,171 @@ const api = {
|
||||
},
|
||||
}
|
||||
|
||||
// 简单的 NCX 解析和生成
|
||||
function parseNcx(content: string): any[] {
|
||||
// NCX 解析 - 支持多种 Navicat 导出格式(异步版本,支持密码解密)
|
||||
async function parseNcxAsync(content: string, electronAPI: Window['electronAPI']): Promise<any[]> {
|
||||
const connections: any[] = []
|
||||
const regex = /<Connection[^>]*\/>/g
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
// 尝试匹配两种格式:
|
||||
// 1. 自闭合: <Connection ... />
|
||||
// 2. 非自闭合: <Connection ...>...</Connection>
|
||||
const selfClosingRegex = /<Connection\s+([^>]*?)\/>/gi
|
||||
const openTagRegex = /<Connection\s+([^>]*?)>/gi
|
||||
|
||||
// 解析属性的辅助函数
|
||||
const parseAttrs = (attrString: string): Record<string, string> => {
|
||||
const attrs: Record<string, string> = {}
|
||||
const attrRegex = /(\w+)="([^"]*)"/g
|
||||
let attrMatch
|
||||
const attrRegex = /(\w+)\s*=\s*"([^"]*)"/g
|
||||
let match
|
||||
while ((match = attrRegex.exec(attrString)) !== null) {
|
||||
attrs[match[1]] = match[2]
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// 从属性创建连接对象(异步,支持密码解密)
|
||||
const createConnection = async (attrs: Record<string, string>) => {
|
||||
// 支持多种属性名称格式
|
||||
const name = attrs.ConnectionName || attrs.Name || attrs.connection_name || ''
|
||||
if (!name) return null
|
||||
|
||||
while ((attrMatch = attrRegex.exec(match[0])) !== null) {
|
||||
attrs[attrMatch[1]] = attrMatch[2]
|
||||
// 类型映射
|
||||
let type = (attrs.ConnType || attrs.Type || attrs.conn_type || 'mysql').toLowerCase()
|
||||
if (type === 'postgresql') type = 'postgres'
|
||||
if (type === 'sql server' || type === 'mssql') type = 'sqlserver'
|
||||
|
||||
// 获取加密的密码
|
||||
const encryptedPassword = attrs.Password || attrs.password || ''
|
||||
let password = ''
|
||||
|
||||
// 如果密码看起来是加密的(全是十六进制字符),则尝试解密
|
||||
if (encryptedPassword && /^[0-9A-Fa-f]+$/.test(encryptedPassword) && encryptedPassword.length >= 16) {
|
||||
try {
|
||||
// 检测 Navicat 版本 - 通过查找 Version 属性或使用默认的 12+
|
||||
const version = parseInt(attrs.Version || attrs.version || '12') || 12
|
||||
password = await electronAPI.decryptNavicatPassword(encryptedPassword, version)
|
||||
console.log(`密码解密成功: ${name} (版本: ${version})`)
|
||||
} catch (e) {
|
||||
console.warn(`密码解密失败: ${name}`, e)
|
||||
// 解密失败时保留原始值(可能是明文密码)
|
||||
password = encryptedPassword
|
||||
}
|
||||
} else {
|
||||
// 如果密码不是十六进制格式,假设是明文
|
||||
password = encryptedPassword
|
||||
}
|
||||
|
||||
if (attrs.ConnectionName) {
|
||||
connections.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: attrs.ConnectionName,
|
||||
type: (attrs.ConnType || 'mysql').toLowerCase(),
|
||||
host: attrs.Host || 'localhost',
|
||||
port: parseInt(attrs.Port) || 3306,
|
||||
username: attrs.UserName || '',
|
||||
password: attrs.Password || '',
|
||||
database: attrs.Database || '',
|
||||
})
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
type,
|
||||
host: attrs.Host || attrs.host || attrs.Server || 'localhost',
|
||||
port: parseInt(attrs.Port || attrs.port || '3306') || 3306,
|
||||
username: attrs.UserName || attrs.Username || attrs.User || attrs.user || '',
|
||||
password,
|
||||
database: attrs.Database || attrs.database || attrs.InitialDatabase || '',
|
||||
}
|
||||
}
|
||||
|
||||
return connections
|
||||
// 收集所有属性字符串
|
||||
const attrStrings: string[] = []
|
||||
|
||||
// 匹配自闭合标签
|
||||
let match
|
||||
while ((match = selfClosingRegex.exec(content)) !== null) {
|
||||
attrStrings.push(match[1])
|
||||
}
|
||||
|
||||
// 匹配非自闭合标签
|
||||
while ((match = openTagRegex.exec(content)) !== null) {
|
||||
attrStrings.push(match[1])
|
||||
}
|
||||
|
||||
// 并行处理所有连接
|
||||
const connectionPromises = attrStrings.map(async (attrString) => {
|
||||
const attrs = parseAttrs(attrString)
|
||||
return await createConnection(attrs)
|
||||
})
|
||||
|
||||
const results = await Promise.all(connectionPromises)
|
||||
|
||||
// 过滤掉 null 值
|
||||
for (const conn of results) {
|
||||
if (conn) connections.push(conn)
|
||||
}
|
||||
|
||||
// 去重(基于名称)
|
||||
const uniqueConnections = connections.filter((conn, index, self) =>
|
||||
index === self.findIndex(c => c.name === conn.name)
|
||||
)
|
||||
|
||||
return uniqueConnections
|
||||
}
|
||||
|
||||
// NCX 解析 - 同步版本(不解密密码,保留兼容性)
|
||||
function parseNcx(content: string): any[] {
|
||||
const connections: any[] = []
|
||||
|
||||
// 尝试匹配两种格式:
|
||||
// 1. 自闭合: <Connection ... />
|
||||
// 2. 非自闭合: <Connection ...>...</Connection>
|
||||
const selfClosingRegex = /<Connection\s+([^>]*?)\/>/gi
|
||||
const openTagRegex = /<Connection\s+([^>]*?)>/gi
|
||||
|
||||
// 解析属性的辅助函数
|
||||
const parseAttrs = (attrString: string): Record<string, string> => {
|
||||
const attrs: Record<string, string> = {}
|
||||
const attrRegex = /(\w+)\s*=\s*"([^"]*)"/g
|
||||
let match
|
||||
while ((match = attrRegex.exec(attrString)) !== null) {
|
||||
attrs[match[1]] = match[2]
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// 从属性创建连接对象
|
||||
const createConnection = (attrs: Record<string, string>) => {
|
||||
// 支持多种属性名称格式
|
||||
const name = attrs.ConnectionName || attrs.Name || attrs.connection_name || ''
|
||||
if (!name) return null
|
||||
|
||||
// 类型映射
|
||||
let type = (attrs.ConnType || attrs.Type || attrs.conn_type || 'mysql').toLowerCase()
|
||||
if (type === 'postgresql') type = 'postgres'
|
||||
if (type === 'sql server' || type === 'mssql') type = 'sqlserver'
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
type,
|
||||
host: attrs.Host || attrs.host || attrs.Server || 'localhost',
|
||||
port: parseInt(attrs.Port || attrs.port || '3306') || 3306,
|
||||
username: attrs.UserName || attrs.Username || attrs.User || attrs.user || '',
|
||||
password: attrs.Password || attrs.password || '',
|
||||
database: attrs.Database || attrs.database || attrs.InitialDatabase || '',
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配自闭合标签
|
||||
let match
|
||||
while ((match = selfClosingRegex.exec(content)) !== null) {
|
||||
const attrs = parseAttrs(match[1])
|
||||
const conn = createConnection(attrs)
|
||||
if (conn) connections.push(conn)
|
||||
}
|
||||
|
||||
// 匹配非自闭合标签
|
||||
while ((match = openTagRegex.exec(content)) !== null) {
|
||||
const attrs = parseAttrs(match[1])
|
||||
const conn = createConnection(attrs)
|
||||
if (conn) connections.push(conn)
|
||||
}
|
||||
|
||||
// 去重(基于名称)
|
||||
const uniqueConnections = connections.filter((conn, index, self) =>
|
||||
index === self.findIndex(c => c.name === conn.name)
|
||||
)
|
||||
|
||||
return uniqueConnections
|
||||
}
|
||||
|
||||
function generateNcx(connections: any[]): string {
|
||||
|
||||
@ -228,9 +228,9 @@ export function useConnections() {
|
||||
|
||||
// 加载保存的连接
|
||||
useEffect(() => {
|
||||
const loadConnections = async () => {
|
||||
const doLoadConnections = async () => {
|
||||
try {
|
||||
const saved = await api.getConnections()
|
||||
const saved = await api.loadConnections()
|
||||
if (saved && Array.isArray(saved)) {
|
||||
setConnections(saved)
|
||||
}
|
||||
@ -238,7 +238,7 @@ export function useConnections() {
|
||||
console.error('加载连接失败:', err)
|
||||
}
|
||||
}
|
||||
loadConnections()
|
||||
doLoadConnections()
|
||||
}, [])
|
||||
|
||||
// 添加连接
|
||||
@ -284,13 +284,29 @@ export function useConnections() {
|
||||
export function useDatabaseOperations(showNotification: (type: 'success' | 'error' | 'info', msg: string) => void) {
|
||||
const [databasesMap, setDatabasesMap] = useState<Map<string, string[]>>(new Map())
|
||||
const [loadingDbSet, setLoadingDbSet] = useState<Set<string>>(new Set())
|
||||
const [loadingConnectionsSet, setLoadingConnectionsSet] = useState<Set<string>>(new Set())
|
||||
|
||||
const fetchDatabases = useCallback(async (connectionId: string) => {
|
||||
// 标记开始加载
|
||||
setLoadingConnectionsSet(prev => new Set(prev).add(connectionId))
|
||||
try {
|
||||
const dbs = await api.getDatabases(connectionId)
|
||||
setDatabasesMap(prev => new Map(prev).set(connectionId, dbs))
|
||||
console.log('获取到数据库列表:', connectionId, dbs)
|
||||
setDatabasesMap(prev => new Map(prev).set(connectionId, dbs || []))
|
||||
if (!dbs || dbs.length === 0) {
|
||||
showNotification('info', '未发现数据库或无权限访问')
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification('error', '获取数据库列表失败')
|
||||
console.error('获取数据库列表失败:', err)
|
||||
showNotification('error', '获取数据库列表失败: ' + (err as Error).message)
|
||||
setDatabasesMap(prev => new Map(prev).set(connectionId, []))
|
||||
} finally {
|
||||
// 标记加载完成
|
||||
setLoadingConnectionsSet(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(connectionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [showNotification])
|
||||
|
||||
@ -299,6 +315,7 @@ export function useDatabaseOperations(showNotification: (type: 'success' | 'erro
|
||||
setDatabasesMap,
|
||||
loadingDbSet,
|
||||
setLoadingDbSet,
|
||||
loadingConnectionsSet,
|
||||
fetchDatabases
|
||||
}
|
||||
}
|
||||
@ -384,13 +401,49 @@ export function useImportExport(
|
||||
const importConnections = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.importConnections()
|
||||
if (result && result.length > 0) {
|
||||
if (result.cancelled) {
|
||||
return // 用户取消了选择
|
||||
}
|
||||
if (!result.success) {
|
||||
showNotification('error', result.error || '导入失败')
|
||||
return
|
||||
}
|
||||
if (result.connections && result.connections.length > 0) {
|
||||
setConnections(prev => {
|
||||
const updated = [...prev, ...result]
|
||||
// 根据连接名称判断是否已存在,存在则覆盖
|
||||
const existingMap = new Map(prev.map(c => [c.name, c]))
|
||||
let updatedCount = 0
|
||||
let newCount = 0
|
||||
|
||||
for (const importedConn of result.connections!) {
|
||||
const existing = existingMap.get(importedConn.name)
|
||||
if (existing) {
|
||||
// 保留原有 ID,覆盖其他信息
|
||||
existingMap.set(importedConn.name, {
|
||||
...importedConn,
|
||||
id: existing.id
|
||||
})
|
||||
updatedCount++
|
||||
} else {
|
||||
// 新增连接
|
||||
existingMap.set(importedConn.name, importedConn)
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
|
||||
const updated = Array.from(existingMap.values())
|
||||
api.saveConnections(updated)
|
||||
|
||||
// 显示详细的导入信息
|
||||
const messages: string[] = []
|
||||
if (newCount > 0) messages.push(`新增 ${newCount} 个`)
|
||||
if (updatedCount > 0) messages.push(`覆盖 ${updatedCount} 个`)
|
||||
showNotification('success', `已从 ${result.source || '文件'} 导入连接:${messages.join(',')}`)
|
||||
|
||||
return updated
|
||||
})
|
||||
showNotification('success', `已导入 ${result.length} 个连接`)
|
||||
} else {
|
||||
showNotification('info', '文件中没有找到连接信息')
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification('error', '导入失败:' + (err as Error).message)
|
||||
|
||||
@ -21,6 +21,7 @@ export interface QueryTab {
|
||||
id: string
|
||||
title: string
|
||||
sql: string
|
||||
connectionId?: string // 所属连接ID
|
||||
results: {
|
||||
columns: string[]
|
||||
rows: any[]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user