Refactor UI components and enhance functionality with a new clean light theme. Updated Tailwind configuration for colors, shadows, and animations. Improved connection and database modals with better state management and error handling. Added hooks for connection management and query operations. Enhanced sidebar and main content for better user experience.
This commit is contained in:
parent
2f907369a0
commit
bca7eff0cd
1519
src/App.tsx
1519
src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -1,353 +1,426 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Loader2, Shield, FolderOpen } from 'lucide-react'
|
||||
import { Connection, DatabaseType, DB_INFO } from '../types'
|
||||
import { X, Database, Check, AlertCircle, ChevronDown, ChevronRight, Shield, Globe, Server, Key, User, Folder, FileText } from 'lucide-react'
|
||||
import { Connection, DB_INFO, DatabaseType } from '../types'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import api from '../lib/electron-api'
|
||||
|
||||
interface Props {
|
||||
connection: Connection | null
|
||||
defaultType?: DatabaseType
|
||||
onSave: (conn: Connection) => void
|
||||
isOpen: boolean
|
||||
editingConnection?: Connection | null
|
||||
initialType?: DatabaseType
|
||||
onClose: () => void
|
||||
onSave: (conn: Omit<Connection, 'id'> & { id?: string }) => void
|
||||
}
|
||||
|
||||
export default function ConnectionModal({ connection, defaultType, onSave, onClose }: Props) {
|
||||
const initialType = defaultType || 'mysql'
|
||||
const initialPort = DB_INFO[initialType]?.port || 3306
|
||||
|
||||
const [form, setForm] = useState<Connection>({
|
||||
id: '',
|
||||
name: '',
|
||||
type: initialType,
|
||||
host: 'localhost',
|
||||
port: initialPort,
|
||||
username: '',
|
||||
password: '',
|
||||
database: '',
|
||||
sshEnabled: false,
|
||||
sshHost: '',
|
||||
sshPort: 22,
|
||||
sshUser: '',
|
||||
sshPassword: '',
|
||||
sshKey: '',
|
||||
})
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null)
|
||||
export default function ConnectionModal({ isOpen, editingConnection, initialType, onClose, onSave }: Props) {
|
||||
const [selectedType, setSelectedType] = useState<DatabaseType>(editingConnection?.type || initialType || 'mysql')
|
||||
const [name, setName] = useState(editingConnection?.name || '')
|
||||
const [host, setHost] = useState(editingConnection?.host || 'localhost')
|
||||
const [port, setPort] = useState(editingConnection?.port || DB_INFO[selectedType].defaultPort)
|
||||
const [username, setUsername] = useState(editingConnection?.username || '')
|
||||
const [password, setPassword] = useState(editingConnection?.password || '')
|
||||
const [database, setDatabase] = useState(editingConnection?.database || '')
|
||||
const [file, setFile] = useState(editingConnection?.file || '')
|
||||
const [useSSH, setUseSSH] = useState(editingConnection?.ssh?.enabled || false)
|
||||
const [sshHost, setSshHost] = useState(editingConnection?.ssh?.host || '')
|
||||
const [sshPort, setSshPort] = useState(editingConnection?.ssh?.port || 22)
|
||||
const [sshUser, setSshUser] = useState(editingConnection?.ssh?.username || '')
|
||||
const [sshPassword, setSshPassword] = useState(editingConnection?.ssh?.password || '')
|
||||
const [sshKeyFile, setSshKeyFile] = useState(editingConnection?.ssh?.privateKeyPath || '')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
setForm(connection)
|
||||
} else {
|
||||
const type = defaultType || 'mysql'
|
||||
const port = DB_INFO[type]?.port || 3306
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
id: `conn-${Date.now()}`,
|
||||
type,
|
||||
port,
|
||||
name: DB_INFO[type]?.name || ''
|
||||
}))
|
||||
if (isOpen) {
|
||||
const timer = setTimeout(() => nameInputRef.current?.focus(), 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [connection, defaultType])
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingConnection) {
|
||||
setSelectedType(editingConnection.type)
|
||||
setName(editingConnection.name)
|
||||
setHost(editingConnection.host || 'localhost')
|
||||
setPort(editingConnection.port || DB_INFO[editingConnection.type].defaultPort)
|
||||
setUsername(editingConnection.username || '')
|
||||
setPassword(editingConnection.password || '')
|
||||
setDatabase(editingConnection.database || '')
|
||||
setFile(editingConnection.file || '')
|
||||
setUseSSH(editingConnection.ssh?.enabled || false)
|
||||
setSshHost(editingConnection.ssh?.host || '')
|
||||
setSshPort(editingConnection.ssh?.port || 22)
|
||||
setSshUser(editingConnection.ssh?.username || '')
|
||||
setSshPassword(editingConnection.ssh?.password || '')
|
||||
setSshKeyFile(editingConnection.ssh?.privateKeyPath || '')
|
||||
} else {
|
||||
const type = initialType || 'mysql'
|
||||
setSelectedType(type)
|
||||
setName('')
|
||||
setHost('localhost')
|
||||
setPort(DB_INFO[type].defaultPort)
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setDatabase('')
|
||||
setFile('')
|
||||
setUseSSH(false)
|
||||
setSshHost('')
|
||||
setSshPort(22)
|
||||
setSshUser('')
|
||||
setSshPassword('')
|
||||
setSshKeyFile('')
|
||||
}
|
||||
setMessage(null)
|
||||
}, [editingConnection, isOpen, initialType])
|
||||
|
||||
const handleTypeChange = (type: DatabaseType) => {
|
||||
const info = DB_INFO[type]
|
||||
setForm(prev => ({ ...prev, type, port: info?.port || prev.port }))
|
||||
setSelectedType(type)
|
||||
setPort(DB_INFO[type].defaultPort)
|
||||
setMessage(null)
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setMessage(null)
|
||||
|
||||
const result = await api.testConnection(form)
|
||||
setMessage({
|
||||
text: result?.message || '测试失败',
|
||||
type: result?.success ? 'success' : 'error'
|
||||
})
|
||||
setTesting(false)
|
||||
try {
|
||||
const connData = buildConnection()
|
||||
const result = await api.testConnection(connData)
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: '连接成功!' })
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || '连接失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '测试失败:' + (err as Error).message })
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000)
|
||||
}
|
||||
|
||||
const buildConnection = (): Omit<Connection, 'id'> & { id?: string } => {
|
||||
const info = DB_INFO[selectedType]
|
||||
return {
|
||||
...(editingConnection?.id ? { id: editingConnection.id } : {}),
|
||||
type: selectedType,
|
||||
name: name || `${info.name} 连接`,
|
||||
host: info.needsHost ? host : undefined,
|
||||
port: info.needsHost ? port : undefined,
|
||||
username: info.needsAuth ? username : undefined,
|
||||
password: info.needsAuth ? password : undefined,
|
||||
database: database || undefined,
|
||||
file: info.needsFile ? file : undefined,
|
||||
ssh: useSSH && info.needsHost ? { enabled: true, host: sshHost, port: sshPort, username: sshUser, password: sshPassword || undefined, privateKeyPath: sshKeyFile || undefined } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.name.trim()) {
|
||||
setMessage({ text: '请输入连接名称', type: 'error' })
|
||||
if (!name.trim()) {
|
||||
setMessage({ type: 'error', text: '请输入连接名称' })
|
||||
setTimeout(() => setMessage(null), 3000)
|
||||
return
|
||||
}
|
||||
onSave(form)
|
||||
onSave(buildConnection())
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
const filePath = await api.selectFile([{ name: 'SQLite', extensions: ['db', 'sqlite', 'sqlite3'] }])
|
||||
if (filePath) setFile(filePath)
|
||||
}
|
||||
|
||||
const handleSelectKeyFile = async () => {
|
||||
const filePath = await api.selectFile([{ name: 'PEM', extensions: ['pem', 'key', 'ppk'] }])
|
||||
if (filePath) setSshKeyFile(filePath)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const info = DB_INFO[selectedType]
|
||||
const isEditing = !!editingConnection
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Metro 风格弹窗 */}
|
||||
<div className="relative w-[560px] max-h-[90vh] bg-metro-bg flex flex-col overflow-hidden shadow-metro-xl animate-slide-up">
|
||||
{/* 标题栏 */}
|
||||
<div className="h-14 bg-accent-blue flex items-center justify-between px-5">
|
||||
<span className="font-semibold text-lg">{connection ? '编辑连接' : '新建连接'}</span>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-white/20 transition-colors rounded-sm">
|
||||
<X size={20} />
|
||||
<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="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">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ backgroundColor: info.color + '15' }}>
|
||||
<span className="text-xl">{info.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-text-primary">
|
||||
{isEditing ? '编辑连接' : '新建连接'}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted">{info.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-light-hover rounded-lg transition-colors">
|
||||
<X size={18} className="text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
{/* 连接名称 */}
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">连接名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="输入名称"
|
||||
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 数据库类型 - Metro 磁贴选择 */}
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-3 font-medium">数据库类型</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][]).map(([key, info]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => info.supported && handleTypeChange(key)}
|
||||
className={`h-16 flex items-center gap-3 px-4 transition-all metro-tile relative
|
||||
${!info.supported ? 'cursor-not-allowed' : ''}
|
||||
${form.type === key && info.supported
|
||||
? 'ring-2 ring-white ring-inset shadow-metro-lg'
|
||||
: info.supported ? 'opacity-60 hover:opacity-100' : ''}`}
|
||||
style={{
|
||||
backgroundColor: info.color,
|
||||
opacity: info.supported ? (form.type === key ? 1 : 0.6) : 0.3,
|
||||
filter: info.supported ? 'none' : 'grayscale(60%)'
|
||||
}}
|
||||
disabled={!info.supported}
|
||||
title={info.supported ? info.name : `${info.name} - 即将支持`}
|
||||
>
|
||||
<span className="text-2xl">{info.icon}</span>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">{info.name}</span>
|
||||
{!info.supported && (
|
||||
<span className="text-[10px] text-white/60">即将支持</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SQLite 文件选择 */}
|
||||
{form.type === 'sqlite' ? (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className="p-5 space-y-5">
|
||||
{/* 数据库类型选择 */}
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">数据库文件</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.database}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, database: e.target.value }))}
|
||||
placeholder="选择或输入 .db 文件路径"
|
||||
className="flex-1 h-10 px-4 bg-metro-surface border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const result = await api.selectFile(['db', 'sqlite', 'sqlite3'])
|
||||
if (result?.path) {
|
||||
setForm(prev => ({ ...prev, database: result.path }))
|
||||
}
|
||||
}}
|
||||
className="h-10 px-4 bg-metro-surface hover:bg-metro-hover flex items-center gap-2 text-sm transition-colors rounded-sm"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
浏览
|
||||
</button>
|
||||
<label className="block text-xs font-medium text-text-secondary mb-2">数据库类型</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][])
|
||||
.filter(([, i]) => i.supported)
|
||||
.map(([type, i]) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleTypeChange(type)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all
|
||||
${selectedType === type
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700 shadow-focus'
|
||||
: 'border-border-default hover:border-border-strong text-text-primary hover:bg-light-hover'}`}
|
||||
>
|
||||
<span className="text-lg">{i.icon}</span>
|
||||
<span className="font-medium">{i.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-text-disabled mt-2">如果文件不存在,将创建新的数据库</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 主机和端口 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">主机</label>
|
||||
|
||||
{/* 连接名称 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-secondary mb-2">
|
||||
<User size={12} className="inline mr-1" />
|
||||
连接名称
|
||||
</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={`我的${info.name}连接`}
|
||||
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SQLite 文件路径 */}
|
||||
{info.needsFile && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-secondary mb-2">
|
||||
<FileText size={12} className="inline mr-1" />
|
||||
数据库文件
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, host: e.target.value }))}
|
||||
placeholder="localhost"
|
||||
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
value={file}
|
||||
onChange={(e) => setFile(e.target.value)}
|
||||
placeholder="选择或输入 .db 文件路径"
|
||||
className="flex-1 h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, port: parseInt(e.target.value) || 0 }))}
|
||||
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户名密码 - Redis 只需要密码 */}
|
||||
{form.type === 'redis' ? (
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">
|
||||
密码 <span className="text-text-disabled font-normal">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="无密码时留空"
|
||||
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">
|
||||
用户名 {form.type === 'mongodb' && <span className="text-text-disabled font-normal">(可选)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder={form.type === 'mongodb' ? '无认证时留空' : 'root'}
|
||||
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">
|
||||
密码 {form.type === 'mongodb' && <span className="text-text-disabled font-normal">(可选)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder={form.type === 'mongodb' ? '无认证时留空' : ''}
|
||||
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据库 */}
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">
|
||||
数据库 <span className="text-text-disabled font-normal">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.database}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, database: e.target.value }))}
|
||||
placeholder={form.type === 'mongodb' ? '默认 admin' : '留空表示连接所有数据库'}
|
||||
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SSH */}
|
||||
<div className="pt-4 border-t border-metro-border">
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.sshEnabled}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, sshEnabled: e.target.checked }))}
|
||||
className="w-5 h-5 accent-accent-blue cursor-pointer"
|
||||
/>
|
||||
<Shield size={18} className={form.sshEnabled ? 'text-accent-green' : 'text-text-disabled'} />
|
||||
<span className="text-sm font-medium group-hover:text-white transition-colors">SSH 隧道连接</span>
|
||||
</label>
|
||||
|
||||
{form.sshEnabled && (
|
||||
<div className="mt-4 p-4 bg-metro-surface rounded-sm space-y-4 border-l-2 border-accent-green">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="col-span-3">
|
||||
<label className="block text-xs text-text-tertiary mb-1.5">SSH 主机</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.sshHost}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, sshHost: e.target.value }))}
|
||||
className="w-full h-9 px-3 bg-metro-bg border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-tertiary mb-1.5">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.sshPort}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, sshPort: parseInt(e.target.value) || 22 }))}
|
||||
className="w-full h-9 px-3 bg-metro-bg border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-text-tertiary mb-1.5">SSH 用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.sshUser}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, sshUser: e.target.value }))}
|
||||
className="w-full h-9 px-3 bg-metro-bg border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-tertiary mb-1.5">SSH 密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.sshPassword}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, sshPassword: e.target.value }))}
|
||||
className="w-full h-9 px-3 bg-metro-bg border-2 border-transparent
|
||||
focus:border-accent-blue text-sm transition-all rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSelectFile}
|
||||
className="h-10 px-4 bg-white hover:bg-light-hover border border-border-default rounded-lg text-sm text-text-primary transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Folder size={14} />
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 消息 */}
|
||||
{message && (
|
||||
<div className={`p-4 text-sm rounded-sm ${message.type === 'success' ? 'bg-accent-green/20 text-accent-green border-l-2 border-accent-green' : 'bg-accent-red/20 text-accent-red border-l-2 border-accent-red'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
{/* 主机和端口 */}
|
||||
{info.needsHost && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-text-secondary mb-2">
|
||||
<Globe size={12} className="inline mr-1" />
|
||||
主机
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="localhost"
|
||||
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-secondary mb-2">
|
||||
<Server size={12} className="inline mr-1" />
|
||||
端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
|
||||
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证信息 */}
|
||||
{info.needsAuth && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-secondary mb-2">
|
||||
<User size={12} className="inline mr-1" />
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="root"
|
||||
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-secondary mb-2">
|
||||
<Key size={12} className="inline mr-1" />
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据库名称 */}
|
||||
{info.needsHost && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-secondary mb-2">
|
||||
<Database size={12} className="inline mr-1" />
|
||||
默认数据库
|
||||
<span className="text-text-muted font-normal ml-1">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={database}
|
||||
onChange={(e) => setDatabase(e.target.value)}
|
||||
placeholder="连接后自动选择的数据库"
|
||||
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH 设置 */}
|
||||
{info.needsHost && (
|
||||
<div className="border border-border-default rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-light-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={14} className="text-teal-500" />
|
||||
<span className="text-sm font-medium text-text-primary">SSH 隧道</span>
|
||||
</div>
|
||||
{showAdvanced ? <ChevronDown size={16} className="text-text-tertiary" /> : <ChevronRight size={16} className="text-text-tertiary" />}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="px-4 pb-4 pt-2 border-t border-border-light bg-light-surface space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useSSH}
|
||||
onChange={(e) => setUseSSH(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border-strong text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">启用 SSH 隧道</span>
|
||||
</label>
|
||||
|
||||
{useSSH && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-text-muted mb-1">SSH 主机</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sshHost}
|
||||
onChange={(e) => setSshHost(e.target.value)}
|
||||
className="w-full h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sshPort}
|
||||
onChange={(e) => setSshPort(parseInt(e.target.value) || 22)}
|
||||
className="w-full h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sshUser}
|
||||
onChange={(e) => setSshUser(e.target.value)}
|
||||
className="w-full h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={sshPassword}
|
||||
onChange={(e) => setSshPassword(e.target.value)}
|
||||
className="w-full h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">私钥文件 <span className="text-text-disabled">(可选)</span></label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={sshKeyFile}
|
||||
onChange={(e) => setSshKeyFile(e.target.value)}
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
className="flex-1 h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
|
||||
/>
|
||||
<button onClick={handleSelectKeyFile}
|
||||
className="h-9 px-3 bg-white hover:bg-light-hover border border-border-default rounded-lg text-sm">
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息提示 */}
|
||||
{message && (
|
||||
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg animate-slide-up
|
||||
${message.type === 'success' ? 'bg-success-50 text-success-600 border border-success-200' : 'bg-danger-50 text-danger-600 border border-danger-200'}`}>
|
||||
{message.type === 'success' ? <Check size={16} /> : <AlertCircle size={16} />}
|
||||
<span className="text-sm">{message.text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="h-16 bg-metro-surface flex items-center justify-end gap-3 px-5 border-t border-metro-border/50">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing}
|
||||
className="h-10 px-5 bg-transparent border border-text-tertiary hover:border-white hover:bg-white/5
|
||||
text-sm transition-all disabled:opacity-50 flex items-center gap-2 rounded-sm"
|
||||
>
|
||||
{testing && <Loader2 size={14} className="animate-spin" />}
|
||||
<div className="h-16 flex items-center justify-end gap-3 px-5 border-t border-border-default bg-light-surface">
|
||||
<button onClick={handleTest}
|
||||
className="h-9 px-4 bg-white hover:bg-light-hover border border-border-default rounded-lg text-sm font-medium text-text-primary transition-colors">
|
||||
测试连接
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="h-10 px-8 bg-accent-blue hover:bg-accent-blue-hover text-sm font-medium transition-all shadow-metro rounded-sm"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-10 px-5 bg-metro-hover hover:bg-metro-border text-sm transition-all rounded-sm"
|
||||
>
|
||||
<button onClick={onClose}
|
||||
className="h-9 px-4 bg-white hover:bg-light-hover border border-border-default rounded-lg text-sm font-medium text-text-primary transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={handleSave}
|
||||
className="h-9 px-5 bg-primary-500 hover:bg-primary-600 text-white rounded-lg text-sm font-medium shadow-btn hover:shadow-btn-hover transition-all">
|
||||
{isEditing ? '保存' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Database } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Database, Settings } from 'lucide-react'
|
||||
import api from '../lib/electron-api'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
connectionId: string | null
|
||||
onClose: () => void
|
||||
onSubmit: (name: string, charset: string, collation: string) => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
// MySQL 字符集和排序规则
|
||||
@ -16,10 +18,21 @@ const CHARSETS = [
|
||||
{ name: 'gb2312', collations: ['gb2312_chinese_ci', 'gb2312_bin'] },
|
||||
]
|
||||
|
||||
export default function CreateDatabaseModal({ isOpen, onClose, onSubmit }: Props) {
|
||||
export default function CreateDatabaseModal({ isOpen, connectionId, onClose, onCreated }: Props) {
|
||||
const [name, setName] = useState('')
|
||||
const [charset, setCharset] = useState('utf8mb4')
|
||||
const [collation, setCollation] = useState('utf8mb4_general_ci')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName('')
|
||||
setCharset('utf8mb4')
|
||||
setCollation('utf8mb4_general_ci')
|
||||
setError('')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@ -34,57 +47,76 @@ export default function CreateDatabaseModal({ isOpen, onClose, onSubmit }: Props
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (name.trim()) {
|
||||
onSubmit(name.trim(), charset, collation)
|
||||
setName('')
|
||||
setCharset('utf8mb4')
|
||||
setCollation('utf8mb4_general_ci')
|
||||
if (!name.trim() || !connectionId) return
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await api.createDatabase(connectionId, name.trim(), charset, collation)
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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-[420px] shadow-metro-lg animate-fade-in">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||
<div className="absolute inset-0 bg-black/20 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<div className="relative bg-white w-[420px] rounded-2xl shadow-modal animate-scale-in overflow-hidden">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-metro-border bg-metro-surface">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database size={18} className="text-accent-blue" />
|
||||
<span className="font-medium">新建数据库</span>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-default">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-teal-50 flex items-center justify-center">
|
||||
<Database size={18} className="text-teal-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-text-primary">新建数据库</h2>
|
||||
<p className="text-xs text-text-muted">创建新的数据库</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-metro-hover rounded-sm transition-colors"
|
||||
className="p-1.5 hover:bg-light-hover rounded-lg transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
<X size={16} className="text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">
|
||||
数据库名称 <span className="text-accent-red">*</span>
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary mb-2 font-medium">
|
||||
<Database size={14} className="text-primary-500" />
|
||||
数据库名称 <span className="text-danger-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(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-light-surface border border-border-default rounded-lg
|
||||
focus:border-primary-500 focus:shadow-focus text-sm transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">字符集</label>
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary mb-2 font-medium">
|
||||
<Settings size={14} className="text-info-500" />
|
||||
字符集
|
||||
</label>
|
||||
<select
|
||||
value={charset}
|
||||
onChange={(e) => handleCharsetChange(e.target.value)}
|
||||
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-light-surface border border-border-default rounded-lg
|
||||
focus:border-primary-500 text-sm transition-all cursor-pointer"
|
||||
>
|
||||
{CHARSETS.map(cs => (
|
||||
<option key={cs.name} value={cs.name}>{cs.name}</option>
|
||||
@ -93,12 +125,12 @@ export default function CreateDatabaseModal({ isOpen, onClose, onSubmit }: Props
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">排序规则</label>
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">排序规则</label>
|
||||
<select
|
||||
value={collation}
|
||||
onChange={(e) => setCollation(e.target.value)}
|
||||
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-light-surface border border-border-default rounded-lg
|
||||
focus:border-primary-500 text-sm transition-all cursor-pointer"
|
||||
>
|
||||
{collations.map(col => (
|
||||
<option key={col} value={col}>{col}</option>
|
||||
@ -106,22 +138,30 @@ export default function CreateDatabaseModal({ isOpen, onClose, onSubmit }: Props
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-danger-50 text-danger-600 text-sm rounded-lg border border-danger-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm bg-metro-surface hover:bg-metro-hover transition-colors"
|
||||
className="px-4 py-2 text-sm bg-light-elevated hover:bg-light-muted border border-border-default
|
||||
rounded-lg transition-colors text-text-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
className="px-4 py-2 text-sm bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-50
|
||||
disabled:cursor-not-allowed transition-colors"
|
||||
disabled={!name.trim() || loading}
|
||||
className="px-4 py-2 text-sm bg-primary-500 hover:bg-primary-600 text-white
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
rounded-lg transition-all font-medium shadow-btn hover:shadow-btn-hover"
|
||||
>
|
||||
创建
|
||||
{loading ? '创建中...' : '创建数据库'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -129,4 +169,3 @@ export default function CreateDatabaseModal({ isOpen, onClose, onSubmit }: Props
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Table2, Plus, Trash2, Key, ArrowUp, ArrowDown } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Table2, Plus, Trash2, Key, ArrowUp, ArrowDown, Check } from 'lucide-react'
|
||||
import api from '../lib/electron-api'
|
||||
|
||||
interface ColumnDef {
|
||||
id: string
|
||||
@ -15,9 +16,10 @@ interface ColumnDef {
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
database: string
|
||||
connectionId: string | null
|
||||
database: string | null
|
||||
onClose: () => void
|
||||
onSubmit: (tableName: string, columns: ColumnDef[]) => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
// 常用数据类型
|
||||
@ -41,11 +43,21 @@ const DEFAULT_COLUMN: Omit<ColumnDef, 'id'> = {
|
||||
comment: '',
|
||||
}
|
||||
|
||||
export default function CreateTableModal({ isOpen, database, onClose, onSubmit }: Props) {
|
||||
export default function CreateTableModal({ isOpen, connectionId, database, onClose, onCreated }: Props) {
|
||||
const [tableName, setTableName] = useState('')
|
||||
const [columns, setColumns] = useState<ColumnDef[]>([
|
||||
{ ...DEFAULT_COLUMN, id: crypto.randomUUID(), name: 'id', primaryKey: true, autoIncrement: true, nullable: false }
|
||||
])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTableName('')
|
||||
setColumns([{ ...DEFAULT_COLUMN, id: crypto.randomUUID(), name: 'id', primaryKey: true, autoIncrement: true, nullable: false }])
|
||||
setError('')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@ -63,11 +75,9 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
|
||||
setColumns(columns.map(col => {
|
||||
if (col.id !== id) return col
|
||||
const updated = { ...col, [field]: value }
|
||||
// 主键不能为空
|
||||
if (field === 'primaryKey' && value) {
|
||||
updated.nullable = false
|
||||
}
|
||||
// 自增必须是主键
|
||||
if (field === 'autoIncrement' && value) {
|
||||
updated.primaryKey = true
|
||||
updated.nullable = false
|
||||
@ -86,63 +96,81 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
|
||||
setColumns(newColumns)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (tableName.trim() && columns.some(c => c.name.trim())) {
|
||||
onSubmit(tableName.trim(), columns.filter(c => c.name.trim()))
|
||||
setTableName('')
|
||||
setColumns([{ ...DEFAULT_COLUMN, id: crypto.randomUUID(), name: 'id', primaryKey: true, autoIncrement: true, nullable: false }])
|
||||
if (!tableName.trim() || !columns.some(c => c.name.trim()) || !connectionId || !database) return
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await api.createTable(connectionId, database, tableName.trim(), columns.filter(c => c.name.trim()))
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要长度
|
||||
const needsLength = (type: string) => {
|
||||
return ['VARCHAR', 'CHAR', 'DECIMAL', 'FLOAT', 'DOUBLE', 'BINARY', 'VARBINARY'].includes(type)
|
||||
}
|
||||
|
||||
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-[800px] max-h-[85vh] flex flex-col shadow-metro-lg animate-fade-in">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||
<div className="absolute inset-0 bg-black/20 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<div className="relative bg-white w-[850px] max-h-[85vh] flex flex-col rounded-2xl shadow-modal animate-scale-in overflow-hidden">
|
||||
{/* 标题栏 */}
|
||||
<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 gap-2">
|
||||
<Table2 size={18} className="text-accent-orange" />
|
||||
<span className="font-medium">新建表 - {database}</span>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-default flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-warning-50 flex items-center justify-center">
|
||||
<Table2 size={18} className="text-warning-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-text-primary">新建表</h2>
|
||||
<p className="text-xs text-text-muted">
|
||||
数据库: <span className="text-teal-600 font-mono">{database}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-metro-hover rounded-sm transition-colors"
|
||||
className="p-1.5 hover:bg-light-hover rounded-lg transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
<X size={16} className="text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
|
||||
{/* 表名 */}
|
||||
<div className="p-4 border-b border-metro-border flex-shrink-0">
|
||||
<label className="block text-sm text-text-secondary mb-1.5">
|
||||
表名称 <span className="text-accent-red">*</span>
|
||||
<div className="p-5 border-b border-border-default flex-shrink-0">
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">
|
||||
表名称 <span className="text-danger-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tableName}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
placeholder="输入表名称"
|
||||
className="w-64 h-9 px-3 bg-metro-surface border border-metro-border text-sm
|
||||
focus:border-accent-blue focus:outline-none transition-colors"
|
||||
className="w-64 h-10 px-3 bg-light-surface border border-border-default rounded-lg
|
||||
focus:border-primary-500 focus:shadow-focus text-sm transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 字段列表 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-text-secondary">字段定义</span>
|
||||
<div className="flex-1 overflow-auto p-5 scrollbar-thin">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-text-secondary font-medium">字段定义</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addColumn}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-accent-blue hover:bg-accent-blue-hover transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary-500 hover:bg-primary-600 text-white
|
||||
rounded-lg transition-colors font-medium"
|
||||
>
|
||||
<Plus size={14} />
|
||||
添加字段
|
||||
@ -150,40 +178,40 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
|
||||
</div>
|
||||
|
||||
{/* 字段表头 */}
|
||||
<div className="flex items-center gap-2 px-2 py-2 bg-metro-surface text-xs text-text-secondary border-b border-metro-border">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-light-surface text-xs text-text-muted border-b border-border-default rounded-t-lg font-medium">
|
||||
<div className="w-8"></div>
|
||||
<div className="w-32">字段名</div>
|
||||
<div className="w-28">类型</div>
|
||||
<div className="w-16">长度</div>
|
||||
<div className="w-12 text-center">主键</div>
|
||||
<div className="w-12 text-center">自增</div>
|
||||
<div className="w-12 text-center">可空</div>
|
||||
<div className="w-24">默认值</div>
|
||||
<div className="w-28">字段名</div>
|
||||
<div className="w-24">类型</div>
|
||||
<div className="w-14">长度</div>
|
||||
<div className="w-10 text-center">主键</div>
|
||||
<div className="w-10 text-center">自增</div>
|
||||
<div className="w-10 text-center">可空</div>
|
||||
<div className="w-20">默认值</div>
|
||||
<div className="flex-1">备注</div>
|
||||
<div className="w-16"></div>
|
||||
<div className="w-10"></div>
|
||||
</div>
|
||||
|
||||
{/* 字段行 */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="border border-t-0 border-border-default rounded-b-lg overflow-hidden">
|
||||
{columns.map((col, index) => (
|
||||
<div key={col.id} className="flex items-center gap-2 px-2 py-1.5 hover:bg-metro-hover/50 group">
|
||||
<div key={col.id} className="flex items-center gap-2 px-3 py-2 hover:bg-light-hover group transition-colors border-b border-border-light last:border-b-0">
|
||||
{/* 排序按钮 */}
|
||||
<div className="w-8 flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveColumn(index, 'up')}
|
||||
disabled={index === 0}
|
||||
className="p-0.5 hover:bg-metro-hover disabled:opacity-30 rounded-sm"
|
||||
className="p-0.5 hover:bg-light-elevated disabled:opacity-30 rounded transition-colors"
|
||||
>
|
||||
<ArrowUp size={10} />
|
||||
<ArrowUp size={10} className="text-text-muted" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveColumn(index, 'down')}
|
||||
disabled={index === columns.length - 1}
|
||||
className="p-0.5 hover:bg-metro-hover disabled:opacity-30 rounded-sm"
|
||||
className="p-0.5 hover:bg-light-elevated disabled:opacity-30 rounded transition-colors"
|
||||
>
|
||||
<ArrowDown size={10} />
|
||||
<ArrowDown size={10} className="text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -193,16 +221,16 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
|
||||
value={col.name}
|
||||
onChange={(e) => updateColumn(col.id, 'name', e.target.value)}
|
||||
placeholder="字段名"
|
||||
className="w-32 h-7 px-2 bg-metro-surface border border-metro-border text-xs
|
||||
focus:border-accent-blue focus:outline-none transition-colors"
|
||||
className="w-28 h-8 px-2 bg-white border border-border-default rounded text-xs
|
||||
focus:border-primary-500 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
|
||||
{/* 类型 */}
|
||||
<select
|
||||
value={col.type}
|
||||
onChange={(e) => updateColumn(col.id, 'type', e.target.value)}
|
||||
className="w-28 h-7 px-2 bg-metro-surface border border-metro-border text-xs
|
||||
focus:border-accent-blue focus:outline-none transition-colors"
|
||||
className="w-24 h-8 px-2 bg-white border border-border-default rounded text-xs
|
||||
focus:border-primary-500 focus:outline-none transition-colors cursor-pointer"
|
||||
>
|
||||
{DATA_TYPES.map(group => (
|
||||
<optgroup key={group.group} label={group.group}>
|
||||
@ -220,41 +248,61 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
|
||||
onChange={(e) => updateColumn(col.id, 'length', e.target.value)}
|
||||
placeholder={needsLength(col.type) ? '长度' : '-'}
|
||||
disabled={!needsLength(col.type)}
|
||||
className="w-16 h-7 px-2 bg-metro-surface border border-metro-border text-xs
|
||||
focus:border-accent-blue focus:outline-none transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-14 h-8 px-2 bg-white border border-border-default rounded text-xs text-center
|
||||
focus:border-primary-500 focus:outline-none transition-colors
|
||||
disabled:opacity-40 disabled:bg-light-surface disabled:cursor-not-allowed font-mono"
|
||||
/>
|
||||
|
||||
{/* 主键 */}
|
||||
<div className="w-12 flex justify-center">
|
||||
<div className="w-10 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateColumn(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-all ${
|
||||
col.primaryKey
|
||||
? 'bg-warning-500 text-white'
|
||||
: 'hover:bg-light-elevated text-text-muted'
|
||||
}`}
|
||||
>
|
||||
<Key size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 自增 */}
|
||||
<div className="w-12 flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.autoIncrement}
|
||||
onChange={(e) => updateColumn(col.id, 'autoIncrement', e.target.checked)}
|
||||
className="w-4 h-4 accent-accent-blue"
|
||||
/>
|
||||
<div className="w-10 flex justify-center">
|
||||
<label className="cursor-pointer">
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all
|
||||
${col.autoIncrement
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'border-border-strong hover:border-primary-300'}`}>
|
||||
{col.autoIncrement && <Check size={10} className="text-white" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.autoIncrement}
|
||||
onChange={(e) => updateColumn(col.id, 'autoIncrement', e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 可空 */}
|
||||
<div className="w-12 flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.nullable}
|
||||
onChange={(e) => updateColumn(col.id, 'nullable', e.target.checked)}
|
||||
disabled={col.primaryKey}
|
||||
className="w-4 h-4 accent-accent-blue disabled:opacity-50"
|
||||
/>
|
||||
<div className="w-10 flex justify-center">
|
||||
<label className={`cursor-pointer ${col.primaryKey ? 'opacity-40 cursor-not-allowed' : ''}`}>
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all
|
||||
${col.nullable
|
||||
? 'bg-success-500 border-success-500'
|
||||
: 'border-border-strong hover:border-success-300'}`}>
|
||||
{col.nullable && <Check size={10} className="text-white" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.nullable}
|
||||
onChange={(e) => updateColumn(col.id, 'nullable', e.target.checked)}
|
||||
disabled={col.primaryKey}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 默认值 */}
|
||||
@ -263,8 +311,8 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
|
||||
value={col.defaultValue}
|
||||
onChange={(e) => updateColumn(col.id, 'defaultValue', e.target.value)}
|
||||
placeholder="默认值"
|
||||
className="w-24 h-7 px-2 bg-metro-surface border border-metro-border text-xs
|
||||
focus:border-accent-blue focus:outline-none transition-colors"
|
||||
className="w-20 h-8 px-2 bg-white border border-border-default rounded text-xs
|
||||
focus:border-primary-500 focus:outline-none transition-colors"
|
||||
/>
|
||||
|
||||
{/* 备注 */}
|
||||
@ -273,18 +321,18 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
|
||||
value={col.comment}
|
||||
onChange={(e) => updateColumn(col.id, 'comment', e.target.value)}
|
||||
placeholder="备注"
|
||||
className="flex-1 h-7 px-2 bg-metro-surface border border-metro-border text-xs
|
||||
focus:border-accent-blue focus:outline-none transition-colors"
|
||||
className="flex-1 h-8 px-2 bg-white border border-border-default rounded text-xs
|
||||
focus:border-primary-500 focus:outline-none transition-colors"
|
||||
/>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<div className="w-16 flex justify-end">
|
||||
<div className="w-10 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeColumn(col.id)}
|
||||
disabled={columns.length === 1}
|
||||
className="p-1.5 text-text-disabled hover:text-accent-red hover:bg-metro-hover
|
||||
rounded-sm transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-1.5 text-text-muted hover:text-danger-500 hover:bg-danger-50
|
||||
rounded transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@ -294,22 +342,30 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-5 mb-3 px-3 py-2 bg-danger-50 text-danger-600 text-sm rounded-lg border border-danger-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-metro-border flex-shrink-0">
|
||||
<div className="flex justify-end gap-2 p-5 border-t border-border-default flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm bg-metro-surface hover:bg-metro-hover transition-colors"
|
||||
className="px-4 py-2 text-sm bg-light-elevated hover:bg-light-muted border border-border-default
|
||||
rounded-lg transition-colors text-text-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!tableName.trim() || !columns.some(c => c.name.trim())}
|
||||
className="px-4 py-2 text-sm bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-50
|
||||
disabled:cursor-not-allowed transition-colors"
|
||||
disabled={!tableName.trim() || !columns.some(c => c.name.trim()) || loading}
|
||||
className="px-4 py-2 text-sm bg-primary-500 hover:bg-primary-600 text-white
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
rounded-lg transition-all font-medium shadow-btn hover:shadow-btn-hover"
|
||||
>
|
||||
创建
|
||||
{loading ? '创建中...' : '创建表'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -317,4 +373,3 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { X, Check } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
@ -9,11 +9,10 @@ interface Props {
|
||||
defaultValue?: string
|
||||
confirmText?: string
|
||||
onClose: () => void
|
||||
onSubmit: (value: string) => void
|
||||
onConfirm: (value: string) => void
|
||||
icon?: React.ReactNode
|
||||
// 用于复制表的额外选项
|
||||
showDataOption?: boolean
|
||||
onSubmitWithData?: (value: string, withData: boolean) => void
|
||||
onConfirmWithData?: (value: string, withData: boolean) => void
|
||||
}
|
||||
|
||||
export default function InputDialog({
|
||||
@ -24,10 +23,10 @@ export default function InputDialog({
|
||||
defaultValue = '',
|
||||
confirmText = '确定',
|
||||
onClose,
|
||||
onSubmit,
|
||||
onConfirm,
|
||||
icon,
|
||||
showDataOption,
|
||||
onSubmitWithData,
|
||||
onConfirmWithData,
|
||||
}: Props) {
|
||||
const [value, setValue] = useState(defaultValue)
|
||||
const [withData, setWithData] = useState(false)
|
||||
@ -41,61 +40,70 @@ export default function InputDialog({
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (value.trim()) {
|
||||
if (showDataOption && onSubmitWithData) {
|
||||
onSubmitWithData(value.trim(), withData)
|
||||
if (showDataOption && onConfirmWithData) {
|
||||
onConfirmWithData(value.trim(), withData)
|
||||
} else {
|
||||
onSubmit(value.trim())
|
||||
onConfirm(value.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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-[380px] shadow-metro-lg animate-fade-in">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||
<div className="absolute inset-0 bg-black/20 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<div className="relative bg-white w-[380px] rounded-2xl shadow-modal animate-scale-in overflow-hidden">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-metro-border bg-metro-surface">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="font-medium">{title}</span>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-default">
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && (
|
||||
<div className="w-8 h-8 rounded-lg bg-light-elevated flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold text-text-primary">{title}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-metro-hover rounded-sm transition-colors"
|
||||
className="p-1.5 hover:bg-light-hover rounded-lg transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
<X size={16} className="text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">
|
||||
{label} <span className="text-accent-red">*</span>
|
||||
<label className="block text-sm text-text-secondary mb-2 font-medium">
|
||||
{label} <span className="text-danger-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={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-light-surface border border-border-default rounded-lg
|
||||
focus:border-primary-500 focus:shadow-focus text-sm transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showDataOption && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all
|
||||
${withData
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'border-border-strong group-hover:border-primary-300'}`}>
|
||||
{withData && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="withData"
|
||||
checked={withData}
|
||||
onChange={(e) => setWithData(e.target.checked)}
|
||||
className="w-4 h-4 accent-accent-blue"
|
||||
className="sr-only"
|
||||
/>
|
||||
<label htmlFor="withData" className="text-sm text-text-secondary cursor-pointer">
|
||||
同时复制表数据
|
||||
</label>
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary">同时复制表数据</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 按钮 */}
|
||||
@ -103,15 +111,17 @@ export default function InputDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm bg-metro-surface hover:bg-metro-hover transition-colors"
|
||||
className="px-4 py-2 text-sm bg-light-elevated hover:bg-light-muted border border-border-default
|
||||
rounded-lg transition-colors text-text-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
className="px-4 py-2 text-sm bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-50
|
||||
disabled:cursor-not-allowed transition-colors"
|
||||
className="px-4 py-2 text-sm bg-primary-500 hover:bg-primary-600 text-white
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
rounded-lg transition-all font-medium shadow-btn hover:shadow-btn-hover"
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
@ -121,4 +131,3 @@ export default function InputDialog({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
import { X, Play, Plus, Minus, Table2, ChevronLeft, ChevronRight, FolderOpen, Save, AlignLeft, Download, FileSpreadsheet, FileCode, Database, RotateCcw, Loader2, Check, RefreshCw } from 'lucide-react'
|
||||
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 { useState, useRef, useEffect, useCallback, memo, Suspense, lazy } from 'react'
|
||||
import { useState, useEffect, useCallback, memo, Suspense, lazy } from 'react'
|
||||
import { format } from 'sql-formatter'
|
||||
import api from '../lib/electron-api'
|
||||
import VirtualDataTable from './VirtualDataTable'
|
||||
|
||||
// 懒加载 Monaco Editor 以提升首次加载性能
|
||||
const SqlEditor = lazy(() => import('./SqlEditor'))
|
||||
|
||||
// 编辑器加载占位组件
|
||||
const EditorLoading = memo(() => (
|
||||
<div className="h-full flex items-center justify-center bg-metro-dark">
|
||||
<div className="h-full flex items-center justify-center bg-light-surface">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent-blue" />
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
|
||||
<span className="text-sm text-text-tertiary">加载编辑器...</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -41,13 +39,12 @@ interface Props {
|
||||
onSaveTableChanges?: (tabId: string) => Promise<void>
|
||||
onDiscardTableChanges?: (tabId: string) => void
|
||||
onRefreshTable?: (tabId: string) => void
|
||||
onAddTableRow?: (tabId: string) => void // 新增行
|
||||
onUpdateNewRow?: (tabId: string, rowIndex: number, colName: string, value: any) => void // 更新新增行
|
||||
onDeleteNewRow?: (tabId: string, rowIndex: number) => void // 删除新增行
|
||||
loadingTables?: Set<string> // 正在加载的表标签ID
|
||||
onAddTableRow?: (tabId: string) => void
|
||||
onUpdateNewRow?: (tabId: string, rowIndex: number, colName: string, value: any) => void
|
||||
onDeleteNewRow?: (tabId: string, rowIndex: number) => void
|
||||
loadingTables?: Set<string>
|
||||
}
|
||||
|
||||
// 主内容组件
|
||||
const MainContent = memo(function MainContent({
|
||||
tabs,
|
||||
activeTab,
|
||||
@ -74,14 +71,11 @@ const MainContent = memo(function MainContent({
|
||||
onDeleteNewRow,
|
||||
loadingTables,
|
||||
}: Props) {
|
||||
// 快捷键处理
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
if (activeTab !== 'welcome') {
|
||||
onCloseTab(activeTab)
|
||||
}
|
||||
if (activeTab !== 'welcome') onCloseTab(activeTab)
|
||||
}
|
||||
if (e.ctrlKey && e.key === 's') {
|
||||
const tab = tabs.find(t => t.id === activeTab)
|
||||
@ -91,7 +85,6 @@ const MainContent = memo(function MainContent({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [activeTab, tabs, onCloseTab, onSaveTableChanges])
|
||||
@ -105,62 +98,60 @@ const MainContent = memo(function MainContent({
|
||||
|
||||
const getTabIcon = (tab: Tab) => {
|
||||
if ('tableName' in tab) {
|
||||
return <Table2 size={12} className="text-accent-orange" />
|
||||
return <Table2 size={13} className="text-warning-500" />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-metro-dark">
|
||||
{/* Metro 风格标签栏 */}
|
||||
<div className="h-10 bg-metro-bg flex items-stretch px-1 border-b border-metro-border/50 overflow-x-auto">
|
||||
<div className="flex-1 flex flex-col bg-white">
|
||||
{/* 标签栏 */}
|
||||
<div className="h-10 bg-light-surface flex items-stretch px-1 border-b border-border-default overflow-x-auto scrollbar-thin">
|
||||
<button
|
||||
onClick={() => onTabChange('welcome')}
|
||||
className={`px-5 text-sm flex items-center transition-all duration-150 shrink-0 relative
|
||||
className={`px-4 text-sm flex items-center gap-1.5 transition-all shrink-0 relative
|
||||
${activeTab === 'welcome'
|
||||
? 'bg-metro-dark text-white font-medium'
|
||||
: 'text-text-secondary hover:text-white hover:bg-metro-hover'}`}
|
||||
? 'text-text-primary font-medium'
|
||||
: 'text-text-tertiary hover:text-text-secondary hover:bg-light-hover'}`}
|
||||
>
|
||||
<Database size={14} className={activeTab === 'welcome' ? 'text-primary-500' : ''} />
|
||||
主页
|
||||
{activeTab === 'welcome' && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-blue" />
|
||||
)}
|
||||
{activeTab === 'welcome' && <span className="tab-indicator" />}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-border-light self-center mx-1" />
|
||||
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`px-4 flex items-center gap-2 text-sm group transition-all duration-150 shrink-0 relative
|
||||
className={`px-3 flex items-center gap-2 text-sm group transition-all shrink-0 relative cursor-pointer
|
||||
${activeTab === tab.id
|
||||
? 'bg-metro-dark text-white font-medium'
|
||||
: 'text-text-secondary hover:text-white hover:bg-metro-hover'}`}
|
||||
? 'text-text-primary font-medium'
|
||||
: 'text-text-tertiary hover:text-text-secondary hover:bg-light-hover'}`}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
<button onClick={() => onTabChange(tab.id)} className="flex items-center gap-2">
|
||||
{getTabIcon(tab)}
|
||||
<span className="max-w-[120px] truncate">{getTabTitle(tab)}</span>
|
||||
</button>
|
||||
{getTabIcon(tab)}
|
||||
<span className="max-w-[120px] truncate">{getTabTitle(tab)}</span>
|
||||
<button
|
||||
onClick={() => onCloseTab(tab.id)}
|
||||
className="opacity-0 group-hover:opacity-100 hover:text-accent-red p-0.5 rounded-sm hover:bg-white/10 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onCloseTab(tab.id) }}
|
||||
className="opacity-0 group-hover:opacity-100 hover:text-danger-500 p-0.5 rounded transition-all"
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={13} />
|
||||
</button>
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-blue" />
|
||||
)}
|
||||
{activeTab === tab.id && <span className="tab-indicator" />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={onNewQuery}
|
||||
className="w-10 flex items-center justify-center text-text-tertiary hover:text-white hover:bg-metro-hover shrink-0 transition-colors"
|
||||
title="新建查询 (Ctrl+Q)"
|
||||
className="w-9 flex items-center justify-center text-text-muted hover:text-primary-500 hover:bg-light-hover shrink-0 transition-colors rounded-lg mx-1 my-1"
|
||||
title="新建查询"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{/* 内容区 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === 'welcome' ? (
|
||||
<WelcomeScreen onNewQuery={onNewQuery} onNewConnectionWithType={onNewConnectionWithType} />
|
||||
@ -198,7 +189,7 @@ const MainContent = memo(function MainContent({
|
||||
)
|
||||
})
|
||||
|
||||
// 欢迎屏幕组件
|
||||
// 欢迎屏幕
|
||||
const WelcomeScreen = memo(function WelcomeScreen({
|
||||
onNewQuery,
|
||||
onNewConnectionWithType
|
||||
@ -207,84 +198,81 @@ const WelcomeScreen = memo(function WelcomeScreen({
|
||||
onNewConnectionWithType?: (type: DatabaseType) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-gradient-to-b from-metro-dark via-metro-dark to-metro-bg relative overflow-hidden">
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent-blue/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-accent-purple/5 rounded-full blur-3xl" />
|
||||
<div className="h-full flex flex-col items-center justify-center bg-gradient-to-b from-white via-light-surface to-light-elevated">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary-500 flex items-center justify-center shadow-btn">
|
||||
<Database size={32} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo 区域 */}
|
||||
<div className="flex items-center gap-4 mb-3 relative z-10">
|
||||
<div className="p-3 bg-gradient-to-br from-accent-blue/20 to-accent-blue/5 rounded-lg">
|
||||
<Database size={48} className="text-accent-blue" />
|
||||
</div>
|
||||
<h1 className="text-5xl font-light tracking-tight text-white">EasySQL</h1>
|
||||
</div>
|
||||
<p className="text-text-tertiary mb-10 text-lg relative z-10">简洁高效的数据库管理工具</p>
|
||||
<h1 className="text-4xl font-bold text-text-primary mb-2">EasySQL</h1>
|
||||
<p className="text-text-tertiary text-lg mb-8">简洁高效的数据库管理工具</p>
|
||||
|
||||
<button
|
||||
onClick={onNewQuery}
|
||||
className="px-10 py-3.5 bg-accent-blue hover:bg-accent-blue-hover text-base font-medium
|
||||
transition-all duration-200 shadow-metro hover:shadow-metro-lg relative z-10
|
||||
hover:translate-y-[-2px]"
|
||||
className="px-8 py-3 bg-primary-500 hover:bg-primary-600 text-white text-base font-medium
|
||||
rounded-xl shadow-btn hover:shadow-btn-hover transition-all flex items-center gap-2"
|
||||
>
|
||||
<Zap size={18} />
|
||||
开始查询
|
||||
</button>
|
||||
|
||||
{/* 数据库磁贴 */}
|
||||
<p className="mt-14 text-text-disabled text-sm tracking-wide relative z-10">快速创建数据库连接</p>
|
||||
<div className="mt-5 grid grid-cols-5 gap-2 relative z-10">
|
||||
{(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][]).slice(0, 5).map(([key, info]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => info.supported && onNewConnectionWithType?.(key)}
|
||||
className={`metro-tile w-24 h-24 flex flex-col items-center justify-center shadow-metro relative
|
||||
${info.supported ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
||||
style={{
|
||||
backgroundColor: info.color,
|
||||
opacity: info.supported ? 1 : 0.4,
|
||||
filter: info.supported ? 'none' : 'grayscale(50%)'
|
||||
}}
|
||||
title={info.supported ? `创建 ${info.name} 连接` : `${info.name} - 即将支持`}
|
||||
disabled={!info.supported}
|
||||
>
|
||||
<span className="text-3xl mb-2">{info.icon}</span>
|
||||
<span className="text-xs font-medium text-white/90">{info.name}</span>
|
||||
{!info.supported && (
|
||||
<span className="absolute bottom-1 text-[10px] text-white/60">即将支持</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* 快捷键 */}
|
||||
<div className="flex items-center gap-6 mt-6 text-xs text-text-muted">
|
||||
<span className="flex items-center gap-2">
|
||||
<kbd className="px-2 py-1 bg-light-elevated rounded border border-border-default font-mono">Ctrl+Q</kbd>
|
||||
新建查询
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<kbd className="px-2 py-1 bg-light-elevated rounded border border-border-default font-mono">Ctrl+Enter</kbd>
|
||||
执行
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 mt-2 relative z-10">
|
||||
{(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][]).slice(5, 9).map(([key, info]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => info.supported && onNewConnectionWithType?.(key)}
|
||||
className={`metro-tile w-24 h-24 flex flex-col items-center justify-center shadow-metro relative
|
||||
${info.supported ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
||||
style={{
|
||||
backgroundColor: info.color,
|
||||
opacity: info.supported ? 1 : 0.4,
|
||||
filter: info.supported ? 'none' : 'grayscale(50%)'
|
||||
}}
|
||||
title={info.supported ? `创建 ${info.name} 连接` : `${info.name} - 即将支持`}
|
||||
disabled={!info.supported}
|
||||
>
|
||||
<span className="text-3xl mb-2">{info.icon}</span>
|
||||
<span className="text-xs font-medium text-white/90">{info.name}</span>
|
||||
{!info.supported && (
|
||||
<span className="absolute bottom-1 text-[10px] text-white/60">即将支持</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* 数据库磁贴 */}
|
||||
<div className="mt-12">
|
||||
<p className="text-center text-text-muted text-sm mb-4 font-medium">支持的数据库</p>
|
||||
|
||||
<div className="flex gap-3 justify-center mb-3">
|
||||
{(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][]).slice(0, 5).map(([key, info]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => info.supported && onNewConnectionWithType?.(key)}
|
||||
className={`db-tile w-20 h-20 flex flex-col items-center justify-center
|
||||
${info.supported ? '' : 'cursor-not-allowed opacity-40'}`}
|
||||
style={{ background: info.color }}
|
||||
title={info.supported ? `创建 ${info.name} 连接` : `${info.name} - 即将支持`}
|
||||
disabled={!info.supported}
|
||||
>
|
||||
<span className="text-3xl mb-1">{info.icon}</span>
|
||||
<span className="text-[10px] font-medium text-white/90">{info.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
{(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][]).slice(5, 9).map(([key, info]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => info.supported && onNewConnectionWithType?.(key)}
|
||||
className={`db-tile w-20 h-20 flex flex-col items-center justify-center
|
||||
${info.supported ? '' : 'cursor-not-allowed opacity-40'}`}
|
||||
style={{ background: info.color }}
|
||||
title={info.supported ? `创建 ${info.name} 连接` : `${info.name} - 即将支持`}
|
||||
disabled={!info.supported}
|
||||
>
|
||||
<span className="text-3xl mb-1">{info.icon}</span>
|
||||
<span className="text-[10px] font-medium text-white/90">{info.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 表格查看器组件
|
||||
// 表格查看器
|
||||
const TableViewer = memo(function TableViewer({
|
||||
tab,
|
||||
isLoading,
|
||||
@ -318,7 +306,6 @@ const TableViewer = memo(function TableViewer({
|
||||
const hasChanges = (tab.pendingChanges?.size || 0) > 0 || (tab.deletedRows?.size || 0) > 0 || (tab.newRows?.length || 0) > 0
|
||||
const primaryKeyCol = tab.columns.find(c => c.key === 'PRI')?.name || tab.columns[0]?.name
|
||||
|
||||
// 计算修改过的单元格
|
||||
const modifiedCells = new Set<string>()
|
||||
tab.pendingChanges?.forEach((changes, rowKey) => {
|
||||
const rowIndex = parseInt(rowKey)
|
||||
@ -327,10 +314,10 @@ const TableViewer = memo(function TableViewer({
|
||||
})
|
||||
})
|
||||
|
||||
// 标记新增行的单元格
|
||||
const newRowCount = tab.newRows?.length || 0
|
||||
const existingDataCount = tab.data.filter((_, i) => !tab.deletedRows?.has(i)).length
|
||||
|
||||
if (newRowCount > 0) {
|
||||
const existingDataCount = tab.data.filter((_, i) => !tab.deletedRows?.has(i)).length
|
||||
for (let i = 0; i < newRowCount; i++) {
|
||||
const rowIndex = existingDataCount + i
|
||||
tab.columns.forEach(col => {
|
||||
@ -339,57 +326,49 @@ 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 changesCount = (tab.pendingChanges?.size || 0) + (tab.deletedRows?.size || 0) + (tab.newRows?.length || 0)
|
||||
const existingDataCount = tab.data.filter((_, i) => !tab.deletedRows?.has(i)).length
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 表信息栏 - 紧凑布局 */}
|
||||
<div className="bg-metro-bg border-b border-metro-border/50 flex items-center justify-between px-3 gap-2" style={{ flexShrink: 0, height: 36 }}>
|
||||
{/* 左侧:表名 */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Table2 size={16} className="text-accent-orange flex-shrink-0" />
|
||||
<span className="font-medium text-white text-sm truncate">{tab.tableName}</span>
|
||||
<span className="text-text-tertiary text-xs flex-shrink-0">({tab.total.toLocaleString()}行)</span>
|
||||
{/* 工具栏 */}
|
||||
<div className="bg-light-surface border-b border-border-default flex items-center justify-between px-4 gap-3" style={{ flexShrink: 0, height: 44 }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-lg border border-border-default">
|
||||
<Table2 size={15} className="text-warning-500" />
|
||||
<span className="font-medium text-text-primary text-sm">{tab.tableName}</span>
|
||||
</div>
|
||||
<span className="text-text-muted text-xs">{tab.total.toLocaleString()} 行</span>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-1.5 text-accent-blue text-xs flex-shrink-0">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
<div className="flex items-center gap-1.5 text-primary-500 text-xs">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:修改提示 */}
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-accent-orange font-medium px-1.5 py-0.5 bg-accent-orange/10 rounded">
|
||||
{changesCount}项待保存
|
||||
{newRowCount > 0 && <span className="ml-1 text-accent-green">+{newRowCount}新增</span>}
|
||||
</span>
|
||||
<div className="px-2.5 py-1 bg-warning-50 text-warning-600 text-xs font-medium rounded-md border border-warning-200">
|
||||
{changesCount} 项待保存
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右侧:分页控件 - 固定宽度 */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => onLoadPage(tab.page - 1)}
|
||||
disabled={tab.page <= 1 || isLoading}
|
||||
className="p-0.5 hover:bg-metro-hover disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="p-1 hover:bg-light-hover disabled:opacity-30 rounded transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="text-xs whitespace-nowrap min-w-[70px] text-center">
|
||||
<span className="text-accent-blue font-medium">{tab.page}</span>/{totalPages}页
|
||||
<span className="text-xs min-w-[70px] text-center">
|
||||
<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-0.5 hover:bg-metro-hover disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="p-1 hover:bg-light-hover disabled:opacity-30 rounded transition-colors"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
@ -397,25 +376,22 @@ const TableViewer = memo(function TableViewer({
|
||||
value={tab.pageSize}
|
||||
onChange={(e) => onChangePageSize?.(parseInt(e.target.value))}
|
||||
disabled={isLoading}
|
||||
className="h-6 px-1 text-xs bg-metro-surface border border-metro-border text-white rounded cursor-pointer hover:border-text-tertiary focus:border-accent-blue outline-none disabled:opacity-50"
|
||||
title="每页条数"
|
||||
className="h-7 px-2 text-xs bg-white border border-border-default rounded cursor-pointer"
|
||||
>
|
||||
<option value={100}>100</option>
|
||||
<option value={500}>500</option>
|
||||
<option value={1000}>1000</option>
|
||||
<option value={2000}>2000</option>
|
||||
<option value={5000}>5000</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据表格 - 使用虚拟滚动 */}
|
||||
{/* 表格 */}
|
||||
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Loading 遮罩 */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-metro-dark/80 flex items-center justify-center z-50">
|
||||
<div className="loading-overlay">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 size={32} className="animate-spin text-accent-blue" />
|
||||
<Loader2 size={28} className="animate-spin text-primary-500" />
|
||||
<span className="text-sm text-text-secondary">加载数据中...</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -429,60 +405,39 @@ const TableViewer = memo(function TableViewer({
|
||||
primaryKeyColumn={primaryKeyCol}
|
||||
modifiedCells={modifiedCells}
|
||||
onCellChange={(visibleRowIndex, colName, value) => {
|
||||
// 判断是修改现有行还是新增行
|
||||
if (visibleRowIndex >= existingDataCount) {
|
||||
// 这是新增的行
|
||||
const newRowIndex = visibleRowIndex - existingDataCount
|
||||
onUpdateNewRow?.(newRowIndex, colName, value)
|
||||
onUpdateNewRow?.(visibleRowIndex - existingDataCount, colName, value)
|
||||
} else {
|
||||
const originalIndex = originalIndexMap[visibleRowIndex]
|
||||
onCellChange?.(originalIndex, colName, value)
|
||||
onCellChange?.(originalIndexMap[visibleRowIndex], colName, value)
|
||||
}
|
||||
}}
|
||||
onDeleteRow={(visibleRowIndex) => {
|
||||
if (visibleRowIndex >= existingDataCount) {
|
||||
// 删除新增的行
|
||||
const newRowIndex = visibleRowIndex - existingDataCount
|
||||
onDeleteNewRow?.(newRowIndex)
|
||||
onDeleteNewRow?.(visibleRowIndex - existingDataCount)
|
||||
} else {
|
||||
const originalIndex = originalIndexMap[visibleRowIndex]
|
||||
onDeleteRow?.(originalIndex)
|
||||
onDeleteRow?.(originalIndexMap[visibleRowIndex])
|
||||
}
|
||||
}}
|
||||
onDeleteRows={(visibleRowIndices) => {
|
||||
const originalIndices: number[] = []
|
||||
const newRowIndices: number[] = []
|
||||
|
||||
visibleRowIndices.forEach(i => {
|
||||
if (i >= existingDataCount) {
|
||||
newRowIndices.push(i - existingDataCount)
|
||||
} else {
|
||||
originalIndices.push(originalIndexMap[i])
|
||||
}
|
||||
})
|
||||
|
||||
if (originalIndices.length > 0) {
|
||||
onDeleteRows?.(originalIndices)
|
||||
}
|
||||
// 从后往前删除新增行,避免索引问题
|
||||
newRowIndices.sort((a, b) => b - a).forEach(i => {
|
||||
onDeleteNewRow?.(i)
|
||||
if (i >= existingDataCount) newRowIndices.push(i - existingDataCount)
|
||||
else originalIndices.push(originalIndexMap[i])
|
||||
})
|
||||
if (originalIndices.length > 0) onDeleteRows?.(originalIndices)
|
||||
newRowIndices.sort((a, b) => b - a).forEach(i => onDeleteNewRow?.(i))
|
||||
}}
|
||||
onRefresh={onRefresh}
|
||||
onSave={onSave}
|
||||
onAddRow={onAddRow}
|
||||
onBatchUpdate={(updates) => {
|
||||
updates.forEach(({ rowIndex, colName, value }) => {
|
||||
// 判断是修改现有行还是新增行
|
||||
if (rowIndex >= existingDataCount) {
|
||||
const newRowIndex = rowIndex - existingDataCount
|
||||
onUpdateNewRow?.(newRowIndex, colName, value)
|
||||
onUpdateNewRow?.(rowIndex - existingDataCount, colName, value)
|
||||
} else {
|
||||
const originalIndex = originalIndexMap[rowIndex]
|
||||
if (originalIndex !== undefined) {
|
||||
onCellChange?.(originalIndex, colName, value)
|
||||
}
|
||||
if (originalIndex !== undefined) onCellChange?.(originalIndex, colName, value)
|
||||
}
|
||||
})
|
||||
}}
|
||||
@ -490,82 +445,35 @@ const TableViewer = memo(function TableViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 - 参考 Navicat 风格 */}
|
||||
<div className="bg-metro-bg border-t border-metro-border/50 flex items-center px-2 gap-1" style={{ flexShrink: 0, height: 32 }}>
|
||||
{/* 左侧:数据操作按钮 */}
|
||||
{/* 底部操作栏 */}
|
||||
<div className="bg-light-surface border-t border-border-default flex items-center px-3 gap-1" style={{ flexShrink: 0, height: 36 }}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={onAddRow}
|
||||
disabled={isLoading}
|
||||
className="w-7 h-7 flex items-center justify-center hover:bg-metro-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors rounded text-text-secondary hover:text-white"
|
||||
title="添加行"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<button onClick={onAddRow} disabled={isLoading}
|
||||
className="w-7 h-7 flex items-center justify-center hover:bg-light-hover disabled:opacity-40 rounded text-text-tertiary hover:text-success-500">
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 如果有选中行,删除选中的行(此功能已在VirtualDataTable中的右键菜单中实现)
|
||||
// 这里作为快捷按钮,可以删除最后一个新增行
|
||||
if (newRowCount > 0) {
|
||||
onDeleteNewRow?.(newRowCount - 1)
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || newRowCount === 0}
|
||||
className="w-7 h-7 flex items-center justify-center hover:bg-metro-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors rounded text-text-secondary hover:text-white"
|
||||
title="删除行"
|
||||
>
|
||||
<Minus size={16} />
|
||||
<button onClick={() => newRowCount > 0 && onDeleteNewRow?.(newRowCount - 1)} disabled={isLoading || newRowCount === 0}
|
||||
className="w-7 h-7 flex items-center justify-center hover:bg-light-hover disabled:opacity-40 rounded text-text-tertiary hover:text-danger-500">
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-metro-border mx-1" />
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isLoading || !hasChanges}
|
||||
className={`w-7 h-7 flex items-center justify-center transition-colors rounded ${
|
||||
hasChanges
|
||||
? 'hover:bg-accent-green/20 text-accent-green'
|
||||
: 'text-text-tertiary opacity-40 cursor-not-allowed'
|
||||
}`}
|
||||
title="保存修改 (Ctrl+S)"
|
||||
>
|
||||
<Check size={16} />
|
||||
<div className="w-px h-4 bg-border-default mx-1" />
|
||||
<button onClick={onSave} disabled={isLoading || !hasChanges}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${hasChanges ? 'hover:bg-success-50 text-success-500' : 'text-text-disabled'}`}>
|
||||
<Check size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDiscard}
|
||||
disabled={isLoading || !hasChanges}
|
||||
className={`w-7 h-7 flex items-center justify-center transition-colors rounded ${
|
||||
hasChanges
|
||||
? 'hover:bg-accent-red/20 text-accent-red'
|
||||
: 'text-text-tertiary opacity-40 cursor-not-allowed'
|
||||
}`}
|
||||
title="放弃修改"
|
||||
>
|
||||
<X size={16} />
|
||||
<button onClick={onDiscard} disabled={isLoading || !hasChanges}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${hasChanges ? 'hover:bg-danger-50 text-danger-500' : 'text-text-disabled'}`}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="w-7 h-7 flex items-center justify-center hover:bg-metro-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors rounded text-text-secondary hover:text-white"
|
||||
title="刷新数据"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<button onClick={onRefresh} disabled={isLoading}
|
||||
className="w-7 h-7 flex items-center justify-center hover:bg-light-hover disabled:opacity-40 rounded text-text-tertiary">
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 中间:状态信息 */}
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-text-tertiary">
|
||||
{hasChanges ? (
|
||||
<span className="text-accent-orange">
|
||||
{tab.pendingChanges?.size || 0}修改 · {tab.deletedRows?.size || 0}删除 · {newRowCount}新增
|
||||
</span>
|
||||
) : (
|
||||
<span>共 {visibleData.length} 行</span>
|
||||
)}
|
||||
<div className="flex-1 text-center text-xs text-text-muted">
|
||||
{hasChanges ? `${tab.pendingChanges?.size || 0} 修改 · ${tab.deletedRows?.size || 0} 删除 · ${newRowCount} 新增` : `共 ${visibleData.length} 行`}
|
||||
</div>
|
||||
|
||||
{/* 右侧:SQL 提示 */}
|
||||
<div className="text-xs text-text-disabled">
|
||||
<div className="text-xs text-text-disabled font-mono">
|
||||
SELECT * FROM `{tab.tableName}` LIMIT {tab.pageSize}
|
||||
</div>
|
||||
</div>
|
||||
@ -573,15 +481,9 @@ const TableViewer = memo(function TableViewer({
|
||||
)
|
||||
})
|
||||
|
||||
// 查询编辑器组件
|
||||
// 查询编辑器
|
||||
const QueryEditor = memo(function QueryEditor({
|
||||
tab,
|
||||
databases,
|
||||
tables,
|
||||
columns,
|
||||
onRun,
|
||||
onUpdateSql,
|
||||
onUpdateTitle
|
||||
tab, databases, tables, columns, onRun, onUpdateSql, onUpdateTitle
|
||||
}: {
|
||||
tab: QueryTab
|
||||
databases: string[]
|
||||
@ -620,12 +522,7 @@ const QueryEditor = memo(function QueryEditor({
|
||||
|
||||
const handleFormat = useCallback(() => {
|
||||
try {
|
||||
const formatted = format(sql, {
|
||||
language: 'mysql',
|
||||
tabWidth: 2,
|
||||
keywordCase: 'upper',
|
||||
linesBetweenQueries: 2,
|
||||
})
|
||||
const formatted = format(sql, { language: 'mysql', tabWidth: 2, keywordCase: 'upper', linesBetweenQueries: 2 })
|
||||
setSql(formatted)
|
||||
onUpdateSql(formatted)
|
||||
} catch (err) {
|
||||
@ -643,203 +540,122 @@ const QueryEditor = memo(function QueryEditor({
|
||||
|
||||
const handleExportCsv = useCallback(async () => {
|
||||
if (!tab.results || tab.results.rows.length === 0) return
|
||||
|
||||
const electronAPI = (window as any).electronAPI
|
||||
if (!electronAPI) return
|
||||
|
||||
const path = await electronAPI.saveDialog({
|
||||
filters: [{ name: 'CSV', extensions: ['csv'] }],
|
||||
defaultPath: `query_results_${Date.now()}.csv`
|
||||
})
|
||||
const path = await electronAPI.saveDialog({ filters: [{ name: 'CSV', extensions: ['csv'] }], defaultPath: `query_${Date.now()}.csv` })
|
||||
if (!path) return
|
||||
|
||||
const columnNames = tab.results.columns
|
||||
const header = columnNames.join(',')
|
||||
const rows = tab.results.rows.map(row =>
|
||||
row.map((v: any) => {
|
||||
if (v === null) return ''
|
||||
if (typeof v === 'string') return `"${v.replace(/"/g, '""')}"`
|
||||
return String(v)
|
||||
}).join(',')
|
||||
).join('\n')
|
||||
|
||||
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')
|
||||
await electronAPI.writeFile(path, `${header}\n${rows}`)
|
||||
}, [tab.results])
|
||||
|
||||
const handleExportSql = useCallback(async () => {
|
||||
if (!tab.results || tab.results.rows.length === 0) return
|
||||
|
||||
const electronAPI = (window as any).electronAPI
|
||||
if (!electronAPI) return
|
||||
|
||||
const path = await electronAPI.saveDialog({
|
||||
filters: [{ name: 'SQL', extensions: ['sql'] }],
|
||||
defaultPath: `query_results_${Date.now()}.sql`
|
||||
})
|
||||
const path = await electronAPI.saveDialog({ filters: [{ name: 'SQL', extensions: ['sql'] }], defaultPath: `query_${Date.now()}.sql` })
|
||||
if (!path) return
|
||||
|
||||
const tableName = 'table_name'
|
||||
const columnNames = tab.results.columns
|
||||
const rows = tab.results.rows
|
||||
|
||||
let sqlContent = `-- 导出时间: ${new Date().toLocaleString()}\n`
|
||||
sqlContent += `-- 共 ${rows.length} 条记录\n\n`
|
||||
|
||||
rows.forEach(row => {
|
||||
const values = row.map((val: any) => {
|
||||
if (val === null) return 'NULL'
|
||||
if (typeof val === 'number') return val
|
||||
return `'${String(val).replace(/'/g, "''")}'`
|
||||
}).join(', ')
|
||||
sqlContent += `INSERT INTO \`${tableName}\` (\`${columnNames.join('`, `')}\`) VALUES (${values});\n`
|
||||
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(', ')
|
||||
sqlContent += `INSERT INTO table_name (\`${tab.results!.columns.join('`, `')}\`) VALUES (${values});\n`
|
||||
})
|
||||
|
||||
await electronAPI.writeFile(path, sqlContent)
|
||||
}, [tab.results])
|
||||
|
||||
// 结果数据转换为表格格式
|
||||
const resultData = tab.results?.rows.map(row => {
|
||||
const obj: Record<string, any> = {}
|
||||
tab.results?.columns.forEach((col, i) => {
|
||||
obj[col] = row[i]
|
||||
})
|
||||
tab.results?.columns.forEach((col, i) => { obj[col] = row[i] })
|
||||
return obj
|
||||
}) || []
|
||||
|
||||
const resultColumns = tab.results?.columns.map(col => {
|
||||
const colInfo = findColumnInfo(col)
|
||||
return {
|
||||
name: col,
|
||||
type: colInfo?.type,
|
||||
key: colInfo?.key,
|
||||
comment: colInfo?.comment,
|
||||
}
|
||||
return { name: col, type: colInfo?.type, key: colInfo?.key, comment: colInfo?.comment }
|
||||
}) || []
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* SQL 编辑区 */}
|
||||
<div style={{ height: '200px', flexShrink: 0, display: 'flex', flexDirection: 'column', borderBottom: '1px solid #5d5d5d' }}>
|
||||
<div className="h-10 bg-metro-bg flex items-center px-2 gap-2" style={{ flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
className="h-7 px-4 bg-accent-green hover:bg-accent-green/90 flex items-center gap-1.5 text-sm transition-colors"
|
||||
title="执行 SQL (Ctrl+Enter)"
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
{/* 工具栏 */}
|
||||
<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 }}>
|
||||
<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" />
|
||||
执行
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-white/20 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={handleOpenFile}
|
||||
className="h-7 px-3 bg-metro-surface hover:bg-metro-surface/80 flex items-center gap-1.5 text-sm transition-colors"
|
||||
title="打开 SQL 文件 (Ctrl+O)"
|
||||
>
|
||||
<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">
|
||||
<FolderOpen size={14} />
|
||||
打开
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSaveFile}
|
||||
className="h-7 px-3 bg-metro-surface hover:bg-metro-surface/80 flex items-center gap-1.5 text-sm transition-colors"
|
||||
title="保存 SQL 文件 (Ctrl+S)"
|
||||
>
|
||||
<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">
|
||||
<Save size={14} />
|
||||
保存
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleFormat}
|
||||
className="h-7 px-3 bg-metro-surface hover:bg-metro-surface/80 flex items-center gap-1.5 text-sm transition-colors"
|
||||
title="格式化 SQL (Ctrl+Shift+F)"
|
||||
>
|
||||
<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">
|
||||
<AlignLeft size={14} />
|
||||
格式化
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-white/20 mx-1" />
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<div className="w-px h-5 bg-border-default mx-1" />
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowExportMenu(!showExportMenu)}
|
||||
className="h-7 px-3 bg-metro-surface hover:bg-metro-surface/80 flex items-center gap-1.5 text-sm transition-colors disabled:opacity-40"
|
||||
title="导出结果"
|
||||
disabled={!tab.results || tab.results.rows.length === 0}
|
||||
>
|
||||
<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">
|
||||
<Download size={14} />
|
||||
导出
|
||||
</button>
|
||||
{showExportMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0" onClick={() => setShowExportMenu(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 bg-metro-surface border border-metro-border rounded shadow-lg z-50 min-w-[140px] animate-fade-in">
|
||||
<button
|
||||
onClick={() => { handleExportCsv(); setShowExportMenu(false) }}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-accent-blue/20 flex items-center gap-2"
|
||||
>
|
||||
<FileSpreadsheet size={14} className="text-accent-green" />
|
||||
导出 CSV
|
||||
<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">
|
||||
<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-accent-blue/20 flex items-center gap-2"
|
||||
>
|
||||
<FileCode size={14} className="text-accent-orange" />
|
||||
导出 SQL
|
||||
<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">
|
||||
<FileCode size={14} className="text-warning-500" /> SQL
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-white/40 ml-auto">
|
||||
{filePath && <span className="mr-3 text-accent-blue">{filePath.split(/[/\\]/).pop()}</span>}
|
||||
Ctrl+Enter 执行 | Ctrl+S 保存
|
||||
<span className="text-xs text-text-muted ml-auto">
|
||||
{filePath && <span className="mr-3 text-primary-500 font-mono">{filePath.split(/[/\\]/).pop()}</span>}
|
||||
<kbd className="px-1.5 py-0.5 bg-light-elevated rounded text-[10px] border border-border-light">Ctrl+Enter</kbd> 执行
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Suspense fallback={<EditorLoading />}>
|
||||
<SqlEditor
|
||||
value={sql}
|
||||
onChange={setSql}
|
||||
onRun={handleRun}
|
||||
onSave={handleSaveFile}
|
||||
onOpen={handleOpenFile}
|
||||
onFormat={handleFormat}
|
||||
databases={databases}
|
||||
tables={tables}
|
||||
columns={columns}
|
||||
/>
|
||||
<SqlEditor value={sql} onChange={setSql} onRun={handleRun} onSave={handleSaveFile} onOpen={handleOpenFile} onFormat={handleFormat}
|
||||
databases={databases} tables={tables} columns={columns} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果区 */}
|
||||
{/* 结果 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div className="h-9 bg-metro-bg flex items-center px-3 border-b border-metro-border" style={{ flexShrink: 0 }}>
|
||||
<span className="text-sm text-white/60">
|
||||
<div className="h-9 bg-light-surface flex items-center px-4 border-b border-border-default" style={{ flexShrink: 0 }}>
|
||||
<span className="text-sm text-text-secondary flex items-center gap-2">
|
||||
<Database size={14} className="text-primary-500" />
|
||||
结果
|
||||
{tab.results && <span className="ml-2 text-white/40">({tab.results.rows.length.toLocaleString()} 行)</span>}
|
||||
{tab.results && <span className="text-text-muted text-xs ml-2">({tab.results.rows.length.toLocaleString()} 行)</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
{tab.results ? (
|
||||
<VirtualDataTable
|
||||
columns={resultColumns}
|
||||
data={resultData}
|
||||
showColumnInfo={true}
|
||||
onRefresh={() => onRun(sql)}
|
||||
/>
|
||||
<VirtualDataTable columns={resultColumns} data={resultData} showColumnInfo={true} onRefresh={() => onRun(sql)} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-white/30">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Database size={32} className="text-white/20" />
|
||||
<span>执行查询以查看结果</span>
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-light-elevated flex items-center justify-center">
|
||||
<Database size={24} className="text-text-disabled" />
|
||||
</div>
|
||||
<span className="text-text-muted">执行查询以查看结果</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -2,7 +2,7 @@ import { Plus, Database, Table2, ChevronRight, ChevronDown, Loader2, HardDrive,
|
||||
import { Connection, DB_INFO, TableInfo } from '../types'
|
||||
import { useState, useEffect, useRef, useCallback, memo } from 'react'
|
||||
|
||||
// Navicat风格的表分组列表
|
||||
// 表分组列表
|
||||
const TableGroupList = memo(function TableGroupList({
|
||||
tables,
|
||||
db,
|
||||
@ -38,7 +38,6 @@ const TableGroupList = memo(function TableGroupList({
|
||||
})
|
||||
}
|
||||
|
||||
// 自动展开表文件夹
|
||||
useEffect(() => {
|
||||
if (regularTables.length > 0) {
|
||||
setExpandedDbs(prev => new Set(prev).add(tablesKey))
|
||||
@ -46,39 +45,40 @@ const TableGroupList = memo(function TableGroupList({
|
||||
}, [regularTables.length, tablesKey, setExpandedDbs])
|
||||
|
||||
if (tables.length === 0) {
|
||||
return <div className="px-3 py-2 text-xs text-text-disabled">无表</div>
|
||||
return <div className="px-4 py-3 text-xs text-text-muted">暂无表</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-0.5">
|
||||
<div className="py-1">
|
||||
{/* 表文件夹 */}
|
||||
{regularTables.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 text-xs text-text-secondary hover:bg-metro-hover hover:text-white cursor-pointer transition-colors rounded-sm"
|
||||
className="flex items-center gap-2 px-3 py-1.5 mx-2 text-xs text-text-secondary hover:bg-light-hover cursor-pointer transition-colors rounded-lg"
|
||||
onClick={() => toggleGroup(tablesKey)}
|
||||
>
|
||||
<span className="text-text-tertiary">
|
||||
<span className="text-text-muted">
|
||||
{isTablesExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<span className="text-accent-orange">
|
||||
{isTablesExpanded ? <FolderOpen size={12} /> : <Folder size={12} />}
|
||||
<span className="text-warning-500">
|
||||
{isTablesExpanded ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</span>
|
||||
<span className="flex-1 font-medium">表</span>
|
||||
<span className="text-text-muted text-[10px] bg-light-elevated px-1.5 py-0.5 rounded-full">
|
||||
{regularTables.length}
|
||||
</span>
|
||||
<span className="flex-1">表</span>
|
||||
<span className="text-text-disabled">{regularTables.length}</span>
|
||||
</div>
|
||||
{isTablesExpanded && (
|
||||
<div className="ml-3 border-l border-metro-border/30">
|
||||
<div className="ml-5 pl-3 border-l border-border-light">
|
||||
{regularTables.map(table => (
|
||||
<div
|
||||
key={table.name}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs text-text-secondary hover:bg-metro-hover hover:text-white cursor-pointer transition-colors rounded-sm mx-0.5"
|
||||
title={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"
|
||||
onClick={() => onOpenTable(connectionId, db, table.name)}
|
||||
onContextMenu={(e) => onContextMenu(e, table.name)}
|
||||
>
|
||||
<Table2 size={12} className="text-accent-orange flex-shrink-0" />
|
||||
<span className="truncate">{table.name}</span>
|
||||
<Table2 size={12} className="text-warning-500 flex-shrink-0" />
|
||||
<span className="truncate font-mono text-[11px]">{table.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -88,32 +88,33 @@ const TableGroupList = memo(function TableGroupList({
|
||||
|
||||
{/* 视图文件夹 */}
|
||||
{views.length > 0 && (
|
||||
<div>
|
||||
<div className="mt-1">
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 text-xs text-text-secondary hover:bg-metro-hover hover:text-white cursor-pointer transition-colors rounded-sm"
|
||||
className="flex items-center gap-2 px-3 py-1.5 mx-2 text-xs text-text-secondary hover:bg-light-hover cursor-pointer transition-colors rounded-lg"
|
||||
onClick={() => toggleGroup(viewsKey)}
|
||||
>
|
||||
<span className="text-text-tertiary">
|
||||
<span className="text-text-muted">
|
||||
{isViewsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
<span className="text-accent-purple">
|
||||
{isViewsExpanded ? <FolderOpen size={12} /> : <Folder size={12} />}
|
||||
<span className="text-info-500">
|
||||
{isViewsExpanded ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</span>
|
||||
<span className="flex-1 font-medium">视图</span>
|
||||
<span className="text-text-muted text-[10px] bg-light-elevated px-1.5 py-0.5 rounded-full">
|
||||
{views.length}
|
||||
</span>
|
||||
<span className="flex-1">视图</span>
|
||||
<span className="text-text-disabled">{views.length}</span>
|
||||
</div>
|
||||
{isViewsExpanded && (
|
||||
<div className="ml-3 border-l border-metro-border/30">
|
||||
<div className="ml-5 pl-3 border-l border-border-light">
|
||||
{views.map(view => (
|
||||
<div
|
||||
key={view.name}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs text-text-secondary hover:bg-metro-hover hover:text-white cursor-pointer transition-colors rounded-sm mx-0.5"
|
||||
title={`${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"
|
||||
onClick={() => onOpenTable(connectionId, db, view.name)}
|
||||
onContextMenu={(e) => onContextMenu(e, view.name)}
|
||||
>
|
||||
<Eye size={12} className="text-accent-purple flex-shrink-0" />
|
||||
<span className="truncate flex-1">{view.name}</span>
|
||||
<Eye size={12} className="text-info-500 flex-shrink-0" />
|
||||
<span className="truncate font-mono text-[11px]">{view.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -128,7 +129,7 @@ interface Props {
|
||||
connections: Connection[]
|
||||
activeConnection: string | null
|
||||
connectedIds: Set<string>
|
||||
databasesMap: Map<string, string[]> // connectionId -> databases[]
|
||||
databasesMap: Map<string, string[]>
|
||||
tablesMap: Map<string, TableInfo[]>
|
||||
selectedDatabase: string | null
|
||||
loadingDbSet: Set<string>
|
||||
@ -145,10 +146,8 @@ interface Props {
|
||||
onExportTable?: (database: string, table: string, format: 'excel' | 'sql' | 'csv') => void
|
||||
onExportConnections?: (format: 'json' | 'ncx') => void
|
||||
onImportConnections?: () => void
|
||||
// 数据库管理
|
||||
onCreateDatabase?: (connectionId: string) => void
|
||||
onDropDatabase?: (connectionId: string, database: string) => void
|
||||
// 表管理
|
||||
onCreateTable?: (connectionId: string, database: string) => void
|
||||
onDropTable?: (connectionId: string, database: string, table: string) => void
|
||||
onTruncateTable?: (connectionId: string, database: string, table: string) => void
|
||||
@ -158,20 +157,17 @@ interface Props {
|
||||
onDesignTable?: (connectionId: string, database: string, table: string) => void
|
||||
}
|
||||
|
||||
// 计算菜单位置,防止超出屏幕
|
||||
function getMenuPosition(x: number, y: number, menuHeight: number = 200, menuWidth: number = 180) {
|
||||
function getMenuPosition(x: number, y: number, menuHeight: number = 200, menuWidth: number = 200) {
|
||||
const windowHeight = window.innerHeight
|
||||
const windowWidth = window.innerWidth
|
||||
|
||||
let finalX = x
|
||||
let finalY = y
|
||||
|
||||
// 如果菜单会超出底部,则向上显示
|
||||
if (y + menuHeight > windowHeight - 10) {
|
||||
finalY = Math.max(10, y - menuHeight)
|
||||
}
|
||||
|
||||
// 如果菜单会超出右侧,则向左显示
|
||||
if (x + menuWidth > windowWidth - 10) {
|
||||
finalX = Math.max(10, x - menuWidth)
|
||||
}
|
||||
@ -214,14 +210,9 @@ export default function Sidebar({
|
||||
const [dbMenu, setDbMenu] = useState<{ x: number; y: number; db: string; connectionId: string } | null>(null)
|
||||
const [tableMenu, setTableMenu] = useState<{ x: number; y: number; db: string; table: string; connectionId: string } | null>(null)
|
||||
const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set())
|
||||
|
||||
// 多选模式
|
||||
const [multiSelectMode, setMultiSelectMode] = useState(false)
|
||||
const [selectedConnections, setSelectedConnections] = useState<Set<string>>(new Set())
|
||||
|
||||
// 搜索功能
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
@ -232,19 +223,16 @@ export default function Sidebar({
|
||||
}
|
||||
}, [selectedDatabase])
|
||||
|
||||
// Ctrl+F 快捷键 - 只在侧边栏有焦点时触发
|
||||
const handleSidebarKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && isFocused) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setShowSearch(true)
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||
}
|
||||
if (e.key === 'Escape' && showSearch) {
|
||||
setShowSearch(false)
|
||||
if (e.key === 'Escape' && searchQuery) {
|
||||
setSearchQuery('')
|
||||
}
|
||||
}, [isFocused, showSearch])
|
||||
}, [isFocused, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const sidebar = sidebarRef.current
|
||||
@ -254,39 +242,30 @@ export default function Sidebar({
|
||||
}
|
||||
}, [handleSidebarKeyDown])
|
||||
|
||||
// 过滤表 - 从 tablesMap 获取指定数据库的表
|
||||
const getFilteredTables = (db: string) => {
|
||||
const dbTables = tablesMap.get(db) || []
|
||||
if (!searchQuery) return dbTables
|
||||
return dbTables.filter(t =>
|
||||
t.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
return dbTables.filter(t => t.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
}
|
||||
|
||||
// 检查数据库是否有匹配的表
|
||||
const dbHasMatchingTables = (db: string) => {
|
||||
if (!searchQuery) return false
|
||||
const dbTables = tablesMap.get(db) || []
|
||||
return dbTables.some(t => t.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
}
|
||||
|
||||
// 过滤数据库:数据库名匹配 或者 该数据库下有匹配的表
|
||||
const getFilteredDatabases = (connDatabases: string[]) => {
|
||||
return connDatabases.filter(db => {
|
||||
if (!searchQuery) return true
|
||||
const query = searchQuery.toLowerCase()
|
||||
// 数据库名匹配
|
||||
if (db.toLowerCase().includes(query)) return true
|
||||
// 检查该数据库是否有匹配的表
|
||||
if (dbHasMatchingTables(db)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索时自动展开有匹配表的数据库
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
// 遍历所有连接的数据库
|
||||
databasesMap.forEach((dbs) => {
|
||||
dbs.forEach(db => {
|
||||
if (dbHasMatchingTables(db)) {
|
||||
@ -301,7 +280,7 @@ export default function Sidebar({
|
||||
<>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="w-72 bg-metro-bg flex flex-col border-r border-metro-border/50 h-full select-none"
|
||||
className="w-64 bg-light-surface flex flex-col h-full select-none border-r border-border-default"
|
||||
tabIndex={0}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={(e) => {
|
||||
@ -312,99 +291,102 @@ export default function Sidebar({
|
||||
onMouseEnter={() => setIsFocused(true)}
|
||||
onMouseLeave={() => setIsFocused(false)}
|
||||
>
|
||||
{/* 新建连接按钮 + 导入导出 */}
|
||||
{/* 头部 */}
|
||||
<div className="p-3 flex-shrink-0 space-y-2">
|
||||
{/* 新建连接按钮 */}
|
||||
<button
|
||||
onClick={onNewConnection}
|
||||
className="w-full h-10 bg-accent-blue hover:bg-accent-blue-hover
|
||||
className="w-full h-9 bg-primary-500 hover:bg-primary-600 text-white
|
||||
flex items-center justify-center gap-2 text-sm font-medium
|
||||
transition-all duration-150 shadow-metro"
|
||||
transition-all rounded-lg shadow-btn hover:shadow-btn-hover"
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.5} />
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
<span>新建连接</span>
|
||||
</button>
|
||||
|
||||
{/* 导入导出按钮 */}
|
||||
{/* 导入导出 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onImportConnections}
|
||||
className="flex-1 h-8 bg-metro-surface hover:bg-metro-hover
|
||||
className="flex-1 h-8 bg-white hover:bg-light-hover border border-border-default
|
||||
flex items-center justify-center gap-1.5 text-xs text-text-secondary
|
||||
transition-all duration-150"
|
||||
title="导入连接 (支持 JSON 和 Navicat NCX 格式)"
|
||||
transition-colors rounded-lg"
|
||||
>
|
||||
<Upload size={14} />
|
||||
<Upload size={13} />
|
||||
<span>导入</span>
|
||||
</button>
|
||||
<div className="relative group flex-1">
|
||||
<button
|
||||
className="w-full h-8 bg-metro-surface hover:bg-metro-hover
|
||||
className="w-full h-8 bg-white hover:bg-light-hover border border-border-default
|
||||
flex items-center justify-center gap-1.5 text-xs text-text-secondary
|
||||
transition-all duration-150"
|
||||
title="导出连接"
|
||||
transition-colors rounded-lg"
|
||||
>
|
||||
<Download size={14} />
|
||||
<Download size={13} />
|
||||
<span>导出</span>
|
||||
</button>
|
||||
{/* 导出格式下拉菜单 */}
|
||||
<div className="absolute left-0 right-0 top-full mt-1 bg-metro-card border border-metro-border
|
||||
shadow-metro-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible
|
||||
transition-all z-50">
|
||||
<div className="absolute left-0 right-0 top-full mt-1 bg-white border border-border-default
|
||||
rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible
|
||||
transition-all z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={() => onExportConnections?.('json')}
|
||||
className="w-full px-3 py-2 text-left text-xs hover:bg-metro-hover flex items-center gap-2"
|
||||
className="w-full px-3 py-2 text-left text-xs hover:bg-light-hover flex items-center gap-2 text-text-primary"
|
||||
>
|
||||
<FileCode size={12} className="text-accent-blue" />
|
||||
导出为 JSON
|
||||
<FileCode size={12} className="text-primary-500" />
|
||||
导出 JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportConnections?.('ncx')}
|
||||
className="w-full px-3 py-2 text-left text-xs hover:bg-metro-hover flex items-center gap-2"
|
||||
className="w-full px-3 py-2 text-left text-xs hover:bg-light-hover flex items-center gap-2 text-text-primary"
|
||||
>
|
||||
<FileText size={12} className="text-accent-orange" />
|
||||
导出为 Navicat (.ncx)
|
||||
<FileText size={12} className="text-warning-500" />
|
||||
导出 Navicat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 - 始终显示 */}
|
||||
{/* 搜索框 */}
|
||||
<div className="px-3 pb-2 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={selectedDatabase ? "搜索表名... (Ctrl+F)" : "搜索数据库... (Ctrl+F)"}
|
||||
className="w-full h-8 pl-9 pr-8 bg-metro-surface text-sm text-white placeholder-text-disabled
|
||||
border border-transparent focus:border-accent-blue transition-all rounded-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-disabled hover:text-white transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索..."
|
||||
className="w-full h-8 pl-9 pr-8 bg-white text-sm text-text-primary placeholder-text-muted
|
||||
border border-border-default focus:border-primary-500 transition-all rounded-lg"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary p-0.5"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接列表 */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-text-tertiary uppercase tracking-wider flex items-center justify-between">
|
||||
<span>连接 ({connections.length})</span>
|
||||
<div className="flex-1 overflow-y-auto min-h-0 scrollbar-thin">
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
连接 · {connections.length}
|
||||
</span>
|
||||
{connections.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMultiSelectMode(!multiSelectMode)
|
||||
if (multiSelectMode) setSelectedConnections(new Set())
|
||||
}}
|
||||
className={`p-1 rounded-sm transition-colors ${multiSelectMode ? 'bg-accent-blue text-white' : 'hover:bg-metro-hover'}`}
|
||||
title={multiSelectMode ? '退出多选' : '批量管理'}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
multiSelectMode
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'hover:bg-light-hover text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{multiSelectMode ? <CheckSquare size={12} /> : <Square size={12} />}
|
||||
</button>
|
||||
@ -413,56 +395,49 @@ export default function Sidebar({
|
||||
|
||||
{/* 多选操作栏 */}
|
||||
{multiSelectMode && selectedConnections.size > 0 && (
|
||||
<div className="px-3 pb-2 flex items-center gap-2">
|
||||
<div className="px-3 pb-2 flex items-center gap-2 animate-fade-in">
|
||||
<span className="text-xs text-text-tertiary">已选 {selectedConnections.size} 项</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`确定删除选中的 ${selectedConnections.size} 个连接吗?`)) {
|
||||
if (confirm(`确定删除 ${selectedConnections.size} 个连接?`)) {
|
||||
onDeleteConnections?.([...selectedConnections])
|
||||
setSelectedConnections(new Set())
|
||||
setMultiSelectMode(false)
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-accent-red/20 text-accent-red hover:bg-accent-red/30 rounded-sm transition-colors flex items-center gap-1"
|
||||
className="px-2 py-1 text-xs bg-danger-50 text-danger-600 hover:bg-danger-100 rounded-md flex items-center gap-1"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<Trash2 size={11} />
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedConnections(new Set())}
|
||||
className="px-2 py-1 text-xs bg-metro-surface hover:bg-metro-hover rounded-sm transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-text-disabled text-sm">
|
||||
暂无连接
|
||||
<div className="px-3 py-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-light-elevated flex items-center justify-center">
|
||||
<Database size={24} className="text-text-muted" />
|
||||
</div>
|
||||
<p className="text-text-muted text-sm">暂无连接</p>
|
||||
<p className="text-text-disabled text-xs mt-1">点击上方按钮创建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 space-y-0.5">
|
||||
<div className="px-2 pb-3 space-y-0.5">
|
||||
{connections.map(conn => {
|
||||
const info = DB_INFO[conn.type]
|
||||
const isConnected = connectedIds.has(conn.id)
|
||||
const isActive = activeConnection === conn.id
|
||||
const isSelected = selectedConnections.has(conn.id)
|
||||
const isExpanded = expandedDbs.has(conn.id)
|
||||
// 获取该连接的数据库列表
|
||||
const connDatabases = databasesMap.get(conn.id) || []
|
||||
// 已展开且有数据库就显示
|
||||
const showDatabases = isExpanded && isConnected && connDatabases.length > 0
|
||||
|
||||
return (
|
||||
<div key={conn.id}>
|
||||
{/* 连接项 */}
|
||||
<div
|
||||
className={`group flex items-center gap-2 px-2 py-2 cursor-pointer transition-all duration-150 rounded-sm
|
||||
${isSelected ? 'bg-metro-hover ring-1 ring-text-tertiary' : ''}
|
||||
${isActive && !isSelected
|
||||
? 'bg-metro-hover'
|
||||
: 'hover:bg-metro-hover'} text-text-secondary hover:text-white`}
|
||||
className={`group flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-all rounded-lg
|
||||
${isSelected ? 'bg-primary-50 ring-1 ring-primary-200' : ''}
|
||||
${isActive && !isSelected ? 'bg-light-hover' : 'hover:bg-light-hover'}`}
|
||||
onClick={() => {
|
||||
if (multiSelectMode) {
|
||||
setSelectedConnections(prev => {
|
||||
@ -473,7 +448,6 @@ export default function Sidebar({
|
||||
})
|
||||
} else {
|
||||
onSelectConnection(conn.id)
|
||||
// 切换展开状态
|
||||
if (isConnected) {
|
||||
setExpandedDbs(prev => {
|
||||
const next = new Set(prev)
|
||||
@ -487,43 +461,35 @@ export default function Sidebar({
|
||||
onDoubleClick={async () => {
|
||||
if (!multiSelectMode && !isConnected) {
|
||||
onConnect(conn)
|
||||
// 连接后自动展开
|
||||
setExpandedDbs(prev => new Set(prev).add(conn.id))
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
const pos = getMenuPosition(e.clientX, e.clientY, 180)
|
||||
const pos = getMenuPosition(e.clientX, e.clientY, 200)
|
||||
setMenu({ x: pos.x, y: pos.y, conn })
|
||||
}}
|
||||
>
|
||||
{/* 复选框/箭头 - 同一列,根据模式显示不同内容 */}
|
||||
<span className="w-4 flex-shrink-0 flex items-center justify-center">
|
||||
{multiSelectMode ? (
|
||||
<span className={`w-4 h-4 rounded-sm border flex items-center justify-center
|
||||
${isSelected ? 'bg-accent-blue border-accent-blue' : 'border-text-tertiary'}`}>
|
||||
{isSelected && <span className="text-white text-xs">✓</span>}
|
||||
<span className={`w-4 h-4 rounded border-2 flex items-center justify-center text-[10px]
|
||||
${isSelected ? 'bg-primary-500 border-primary-500 text-white' : 'border-border-strong'}`}>
|
||||
{isSelected && '✓'}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${isConnected ? 'text-text-tertiary' : 'opacity-0'}`}>
|
||||
<span className={isConnected ? 'text-text-muted' : 'opacity-0'}>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-lg flex-shrink-0">{info?.icon}</span>
|
||||
<span className="flex-1 text-sm truncate font-medium">{conn.name}</span>
|
||||
{/* 连接状态灯 - 右对齐 */}
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 transition-all ${isConnected
|
||||
? 'bg-[#00ff00] shadow-[0_0_8px_#00ff00,0_0_12px_#00ff00]'
|
||||
: 'bg-text-disabled/40'}`}
|
||||
title={isConnected ? '已连接' : '未连接'}
|
||||
/>
|
||||
<span className="flex-1 text-sm truncate font-medium text-text-primary">{conn.name}</span>
|
||||
<span className={`status-dot flex-shrink-0 ${isConnected ? 'connected' : 'disconnected'}`} />
|
||||
</div>
|
||||
|
||||
{/* 数据库列表 - 嵌套在连接下 */}
|
||||
{showDatabases && isExpanded && (
|
||||
<div className="ml-4 border-l border-metro-border/50 mt-0.5">
|
||||
{/* 数据库列表 */}
|
||||
{showDatabases && (
|
||||
<div className="ml-5 mt-0.5 pl-3 border-l border-border-light animate-slide-down">
|
||||
{getFilteredDatabases(connDatabases).map(db => {
|
||||
const isDbSelected = selectedDatabase === db
|
||||
const isDbExpanded = expandedDbs.has(db)
|
||||
@ -533,16 +499,11 @@ export default function Sidebar({
|
||||
return (
|
||||
<div key={db}>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1.5 cursor-pointer text-sm transition-all duration-150 rounded-sm ml-1
|
||||
${isDbSelected
|
||||
? 'bg-metro-hover text-white font-medium'
|
||||
: 'text-text-secondary hover:bg-metro-hover hover:text-white'}`}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 cursor-pointer text-sm transition-all rounded-lg mx-1
|
||||
${isDbSelected ? 'bg-primary-50 text-primary-700' : 'text-text-secondary hover:bg-light-hover'}`}
|
||||
onClick={() => {
|
||||
const willExpand = !expandedDbs.has(db)
|
||||
// 展开时自动选择数据库以加载表
|
||||
if (willExpand) {
|
||||
onSelectDatabase(db, conn.id)
|
||||
}
|
||||
if (willExpand) onSelectDatabase(db, conn.id)
|
||||
setExpandedDbs(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(db)) next.delete(db)
|
||||
@ -552,23 +513,22 @@ export default function Sidebar({
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
const pos = getMenuPosition(e.clientX, e.clientY, 200)
|
||||
const pos = getMenuPosition(e.clientX, e.clientY, 220)
|
||||
setDbMenu({ x: pos.x, y: pos.y, db, connectionId: conn.id })
|
||||
}}
|
||||
>
|
||||
<span className={`flex-shrink-0 ${isDbSelected ? 'text-white/70' : 'text-text-tertiary'}`}>
|
||||
<span className="text-text-muted">
|
||||
{isDbExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<Database size={14} className={`flex-shrink-0 ${isDbSelected ? 'text-white' : 'text-accent-blue'}`} />
|
||||
<Database size={14} className={isDbSelected ? 'text-primary-500' : 'text-teal-500'} />
|
||||
<span className="flex-1 truncate">{db}</span>
|
||||
</div>
|
||||
|
||||
{/* 表列表 - Navicat风格分组 */}
|
||||
{isDbExpanded && (
|
||||
<div className="ml-4 mt-0.5">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-text-tertiary">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-text-muted">
|
||||
<Loader2 size={12} className="animate-spin text-primary-500" />
|
||||
加载中...
|
||||
</div>
|
||||
) : (
|
||||
@ -581,7 +541,7 @@ export default function Sidebar({
|
||||
onOpenTable={onOpenTable}
|
||||
onContextMenu={(e, tableName) => {
|
||||
e.preventDefault()
|
||||
const pos = getMenuPosition(e.clientX, e.clientY, 280)
|
||||
const pos = getMenuPosition(e.clientX, e.clientY, 320)
|
||||
setTableMenu({ x: pos.x, y: pos.y, db, table: tableName, connectionId: conn.id })
|
||||
}}
|
||||
/>
|
||||
@ -601,52 +561,54 @@ export default function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metro 风格右键菜单 - 连接 */}
|
||||
{/* 右键菜单 - 连接 */}
|
||||
{menu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenu(null)} />
|
||||
<div
|
||||
className="fixed z-50 bg-metro-card border border-metro-border py-1.5 min-w-[160px] shadow-metro-lg animate-fade-in"
|
||||
className="fixed z-50 bg-white border border-border-default py-1.5 min-w-[180px] rounded-xl shadow-modal menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
>
|
||||
{connectedIds.has(menu.conn.id) ? (
|
||||
<>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onDisconnect(menu.conn.id); setMenu(null) }}
|
||||
>
|
||||
<span className="w-4 h-4 rounded-full border-2 border-accent-red" />
|
||||
<span className="w-3 h-3 rounded-full border-2 border-danger-500" />
|
||||
断开连接
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onCreateDatabase?.(menu.conn.id); setMenu(null) }}
|
||||
>
|
||||
<PlusCircle size={14} className="text-accent-green" />
|
||||
<PlusCircle size={14} className="text-success-500" />
|
||||
新建数据库
|
||||
</button>
|
||||
<div className="my-1 border-t border-metro-border" />
|
||||
<div className="my-1.5 mx-2 border-t border-border-light" />
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
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) }}
|
||||
>
|
||||
<span className="w-4 h-4 rounded-full border-2 border-accent-green" />
|
||||
<span className="w-3 h-3 rounded-full border-2 border-success-500" />
|
||||
连接
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onEditConnection(menu.conn); setMenu(null) }}
|
||||
>
|
||||
<Edit3 size={14} className="text-text-muted" />
|
||||
编辑
|
||||
</button>
|
||||
<div className="my-1 border-t border-metro-border" />
|
||||
<div className="my-1.5 mx-2 border-t border-border-light" />
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover text-accent-red transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-danger-50 text-danger-600 flex items-center gap-3"
|
||||
onClick={() => { onDeleteConnection(menu.conn.id); setMenu(null) }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
@ -658,36 +620,36 @@ export default function Sidebar({
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setDbMenu(null)} />
|
||||
<div
|
||||
className="fixed z-50 bg-metro-card border border-metro-border py-1.5 min-w-[180px] shadow-metro-lg animate-fade-in"
|
||||
className="fixed z-50 bg-white border border-border-default py-1.5 min-w-[180px] rounded-xl shadow-modal menu"
|
||||
style={{ left: dbMenu.x, top: dbMenu.y }}
|
||||
>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onCreateTable?.(dbMenu.connectionId, dbMenu.db); setDbMenu(null) }}
|
||||
>
|
||||
<PlusCircle size={14} className="text-accent-green" />
|
||||
<PlusCircle size={14} className="text-success-500" />
|
||||
新建表
|
||||
</button>
|
||||
<div className="my-1 border-t border-metro-border" />
|
||||
<div className="my-1.5 mx-2 border-t border-border-light" />
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onRefreshTables?.(dbMenu.connectionId, dbMenu.db); setDbMenu(null) }}
|
||||
>
|
||||
<RefreshCw size={14} className="text-text-secondary" />
|
||||
<RefreshCw size={14} className="text-text-muted" />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onBackupDatabase?.(dbMenu.db); setDbMenu(null) }}
|
||||
>
|
||||
<HardDrive size={14} className="text-accent-blue" />
|
||||
备份数据库
|
||||
<HardDrive size={14} className="text-primary-500" />
|
||||
备份
|
||||
</button>
|
||||
<div className="my-1 border-t border-metro-border" />
|
||||
<div className="my-1.5 mx-2 border-t border-border-light" />
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 text-accent-red transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-danger-50 text-danger-600 flex items-center gap-3"
|
||||
onClick={() => {
|
||||
if (confirm(`确定要删除数据库 "${dbMenu.db}" 吗?此操作不可恢复!`)) {
|
||||
if (confirm(`确定删除数据库 "${dbMenu.db}"?`)) {
|
||||
onDropDatabase?.(dbMenu.connectionId, dbMenu.db)
|
||||
}
|
||||
setDbMenu(null)
|
||||
@ -705,68 +667,68 @@ export default function Sidebar({
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setTableMenu(null)} />
|
||||
<div
|
||||
className="fixed z-50 bg-metro-card border border-metro-border py-1.5 min-w-[180px] shadow-metro-lg animate-fade-in"
|
||||
className="fixed z-50 bg-white border border-border-default py-1.5 min-w-[180px] rounded-xl shadow-modal menu"
|
||||
style={{ left: tableMenu.x, top: tableMenu.y }}
|
||||
>
|
||||
<div className="px-4 py-1.5 text-xs text-text-disabled border-b border-metro-border mb-1">
|
||||
<div className="px-3 py-1.5 text-xs text-text-muted border-b border-border-light mb-1 font-mono">
|
||||
{tableMenu.table}
|
||||
</div>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onOpenTable(tableMenu.connectionId, tableMenu.db, tableMenu.table); setTableMenu(null) }}
|
||||
>
|
||||
<Table2 size={14} className="text-accent-orange" />
|
||||
<Table2 size={14} className="text-warning-500" />
|
||||
打开表
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onDesignTable?.(tableMenu.connectionId, tableMenu.db, tableMenu.table); setTableMenu(null) }}
|
||||
>
|
||||
<Settings size={14} className="text-accent-teal" />
|
||||
<Settings size={14} className="text-teal-500" />
|
||||
设计表
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onRenameTable?.(tableMenu.connectionId, tableMenu.db, tableMenu.table); setTableMenu(null) }}
|
||||
>
|
||||
<Edit3 size={14} className="text-accent-blue" />
|
||||
<Edit3 size={14} className="text-primary-500" />
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onDuplicateTable?.(tableMenu.connectionId, tableMenu.db, tableMenu.table); setTableMenu(null) }}
|
||||
>
|
||||
<Copy size={14} className="text-accent-purple" />
|
||||
<Copy size={14} className="text-info-500" />
|
||||
复制表
|
||||
</button>
|
||||
<div className="my-1 border-t border-metro-border" />
|
||||
<div className="px-4 py-1.5 text-xs text-text-disabled">导出</div>
|
||||
<div className="my-1.5 mx-2 border-t border-border-light" />
|
||||
<div className="px-3 py-1 text-[10px] text-text-muted uppercase">导出</div>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-1.5 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onExportTable?.(tableMenu.db, tableMenu.table, 'excel'); setTableMenu(null) }}
|
||||
>
|
||||
<FileSpreadsheet size={14} className="text-accent-green" />
|
||||
导出 Excel
|
||||
<FileSpreadsheet size={14} className="text-success-500" />
|
||||
Excel
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-1.5 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onExportTable?.(tableMenu.db, tableMenu.table, 'sql'); setTableMenu(null) }}
|
||||
>
|
||||
<FileCode size={14} className="text-accent-orange" />
|
||||
导出 SQL
|
||||
<FileCode size={14} className="text-warning-500" />
|
||||
SQL
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 transition-colors"
|
||||
className="w-full px-3 py-1.5 text-left text-sm hover:bg-light-hover flex items-center gap-3 text-text-secondary"
|
||||
onClick={() => { onExportTable?.(tableMenu.db, tableMenu.table, 'csv'); setTableMenu(null) }}
|
||||
>
|
||||
<FileText size={14} className="text-accent-blue" />
|
||||
导出 CSV
|
||||
<FileText size={14} className="text-primary-500" />
|
||||
CSV
|
||||
</button>
|
||||
<div className="my-1 border-t border-metro-border" />
|
||||
<div className="my-1.5 mx-2 border-t border-border-light" />
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 text-accent-orange transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-warning-50 text-warning-600 flex items-center gap-3"
|
||||
onClick={() => {
|
||||
if (confirm(`确定要清空表 "${tableMenu.table}" 的所有数据吗?此操作不可恢复!`)) {
|
||||
if (confirm(`确定清空表 "${tableMenu.table}"?`)) {
|
||||
onTruncateTable?.(tableMenu.connectionId, tableMenu.db, tableMenu.table)
|
||||
}
|
||||
setTableMenu(null)
|
||||
@ -776,9 +738,9 @@ export default function Sidebar({
|
||||
清空表
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-metro-hover flex items-center gap-3 text-accent-red transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-danger-50 text-danger-600 flex items-center gap-3"
|
||||
onClick={() => {
|
||||
if (confirm(`确定要删除表 "${tableMenu.table}" 吗?此操作不可恢复!`)) {
|
||||
if (confirm(`确定删除表 "${tableMenu.table}"?`)) {
|
||||
onDropTable?.(tableMenu.connectionId, tableMenu.db, tableMenu.table)
|
||||
}
|
||||
setTableMenu(null)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Minus, Square, X, Database, Copy } from 'lucide-react'
|
||||
import { Minus, Square, X, Database, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { memo, useState } from 'react'
|
||||
import api from '../lib/electron-api'
|
||||
|
||||
@ -11,46 +11,44 @@ const TitleBar = memo(function TitleBar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-9 bg-metro-dark flex items-center justify-between drag select-none border-b border-metro-border/30 relative">
|
||||
{/* 微妙的顶部高光效果 */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/5 to-transparent" />
|
||||
|
||||
{/* Logo */}
|
||||
<div className="h-10 bg-white flex items-center justify-between drag select-none border-b border-border-default">
|
||||
{/* Logo 区域 */}
|
||||
<div className="flex items-center h-full px-4 no-drag gap-2.5">
|
||||
<div className="relative">
|
||||
<Database size={16} className="text-accent-blue" />
|
||||
<div className="absolute inset-0 bg-accent-blue/20 blur-md -z-10" />
|
||||
<div className="w-7 h-7 rounded-lg bg-primary-500 flex items-center justify-center">
|
||||
<Database size={15} className="text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-wide text-white/90">EasySQL</span>
|
||||
<span className="text-[10px] text-white/30 font-medium ml-1">v2.0</span>
|
||||
<span className="text-sm font-semibold text-text-primary">EasySQL</span>
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary-50 text-primary-600">
|
||||
v2.0
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Window Controls - Windows 11 风格 */}
|
||||
{/* 窗口控制按钮 */}
|
||||
<div className="flex h-full no-drag">
|
||||
<button
|
||||
onClick={() => api.minimize()}
|
||||
className="w-12 h-full flex items-center justify-center hover:bg-white/10 transition-colors duration-150 group"
|
||||
className="w-11 h-full flex items-center justify-center hover:bg-light-hover transition-colors"
|
||||
title="最小化"
|
||||
>
|
||||
<Minus size={16} className="text-white/60 group-hover:text-white/90" />
|
||||
<Minus size={15} className="text-text-tertiary" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="w-12 h-full flex items-center justify-center hover:bg-white/10 transition-colors duration-150 group"
|
||||
className="w-11 h-full flex items-center justify-center hover:bg-light-hover transition-colors"
|
||||
title={isMaximized ? "还原" : "最大化"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Copy size={11} className="text-white/60 group-hover:text-white/90" />
|
||||
<Minimize2 size={13} className="text-text-tertiary" />
|
||||
) : (
|
||||
<Square size={11} className="text-white/60 group-hover:text-white/90" />
|
||||
<Maximize2 size={13} className="text-text-tertiary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => api.close()}
|
||||
className="w-12 h-full flex items-center justify-center hover:bg-accent-red transition-colors duration-150 group"
|
||||
className="w-11 h-full flex items-center justify-center hover:bg-danger-500 hover:text-white transition-colors group"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={16} className="text-white/60 group-hover:text-white" />
|
||||
<X size={15} className="text-text-tertiary group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -721,7 +721,7 @@ const VirtualDataTable = memo(function VirtualDataTable({
|
||||
minWidth: colWidth,
|
||||
position: isPinned ? 'sticky' : 'relative',
|
||||
left: isPinned ? pinnedLeftOffsets[col.name] : 'auto',
|
||||
boxShadow: isPinned && scrollLeft > 0 ? '2px 0 4px rgba(0,0,0,0.3)' : 'none',
|
||||
boxShadow: isPinned && scrollLeft > 0 ? '2px 0 4px rgba(0,0,0,0.05)' : 'none',
|
||||
height: headerHeight,
|
||||
}}
|
||||
title={isPinned ? `取消固定 ${col.name}` : `固定 ${col.name}`}
|
||||
@ -832,14 +832,14 @@ const VirtualDataTable = memo(function VirtualDataTable({
|
||||
: formatDateTime(value, col.type || '')
|
||||
}
|
||||
|
||||
// 计算背景色
|
||||
// 计算背景色 - 浅色主题
|
||||
let bgColor = 'transparent'
|
||||
if (isCurrentMatch) bgColor = '#665500'
|
||||
else if (isSearchMatch) bgColor = 'rgba(255, 200, 0, 0.15)'
|
||||
else if (isActiveCell) bgColor = '#264f78'
|
||||
else if (isCellSelected) bgColor = 'rgba(38, 79, 120, 0.5)'
|
||||
else if (isModified) bgColor = 'rgba(249, 115, 22, 0.15)'
|
||||
else if (isPinned) bgColor = '#1e2d3d'
|
||||
if (isCurrentMatch) bgColor = '#fef08a'
|
||||
else if (isSearchMatch) bgColor = 'rgba(250, 204, 21, 0.2)'
|
||||
else if (isActiveCell) bgColor = 'rgba(59, 130, 246, 0.15)'
|
||||
else if (isCellSelected) bgColor = 'rgba(59, 130, 246, 0.1)'
|
||||
else if (isModified) bgColor = 'rgba(249, 115, 22, 0.1)'
|
||||
else if (isPinned) bgColor = '#f8fafc'
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -853,8 +853,8 @@ const VirtualDataTable = memo(function VirtualDataTable({
|
||||
minWidth: colWidth,
|
||||
maxWidth: colWidth,
|
||||
height: rowHeight,
|
||||
boxShadow: isPinned && scrollLeft > 0 ? '2px 0 4px rgba(0,0,0,0.3)' : 'none',
|
||||
outline: isActiveCell && !isEditing ? '1px solid #007acc' : 'none',
|
||||
boxShadow: isPinned && scrollLeft > 0 ? '2px 0 4px rgba(0,0,0,0.05)' : 'none',
|
||||
outline: isActiveCell && !isEditing ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: '-1px',
|
||||
zIndex: isPinned ? 10 : 1,
|
||||
}}
|
||||
|
||||
1073
src/index.css
1073
src/index.css
File diff suppressed because it is too large
Load Diff
196
src/lib/hooks.ts
196
src/lib/hooks.ts
@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { Connection, QueryTab, TableInfo, ColumnInfo, TableTab } from '../types'
|
||||
import api from './electron-api'
|
||||
|
||||
// 防抖 Hook
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
@ -215,3 +217,197 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
return [storedValue, setValue] as const
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 业务 Hooks
|
||||
// ============================================
|
||||
|
||||
// 连接管理 Hook
|
||||
export function useConnections() {
|
||||
const [connections, setConnections] = useState<Connection[]>([])
|
||||
const [connectedIds, setConnectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// 加载保存的连接
|
||||
useEffect(() => {
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
const saved = await api.getConnections()
|
||||
if (saved && Array.isArray(saved)) {
|
||||
setConnections(saved)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载连接失败:', err)
|
||||
}
|
||||
}
|
||||
loadConnections()
|
||||
}, [])
|
||||
|
||||
// 添加连接
|
||||
const addConnection = useCallback((conn: Omit<Connection, 'id'>) => {
|
||||
const newConn: Connection = { ...conn, id: `conn-${Date.now()}` } as Connection
|
||||
setConnections(prev => {
|
||||
const updated = [...prev, newConn]
|
||||
api.saveConnections(updated)
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 删除连接
|
||||
const deleteConnection = useCallback((id: string) => {
|
||||
setConnections(prev => {
|
||||
const updated = prev.filter(c => c.id !== id)
|
||||
api.saveConnections(updated)
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 更新连接
|
||||
const updateConnection = useCallback((conn: Connection) => {
|
||||
setConnections(prev => {
|
||||
const updated = prev.map(c => c.id === conn.id ? conn : c)
|
||||
api.saveConnections(updated)
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
connections,
|
||||
setConnections,
|
||||
connectedIds,
|
||||
setConnectedIds,
|
||||
addConnection,
|
||||
deleteConnection,
|
||||
updateConnection
|
||||
}
|
||||
}
|
||||
|
||||
// 数据库操作 Hook
|
||||
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 fetchDatabases = useCallback(async (connectionId: string) => {
|
||||
try {
|
||||
const dbs = await api.getDatabases(connectionId)
|
||||
setDatabasesMap(prev => new Map(prev).set(connectionId, dbs))
|
||||
} catch (err) {
|
||||
showNotification('error', '获取数据库列表失败')
|
||||
}
|
||||
}, [showNotification])
|
||||
|
||||
return {
|
||||
databasesMap,
|
||||
setDatabasesMap,
|
||||
loadingDbSet,
|
||||
setLoadingDbSet,
|
||||
fetchDatabases
|
||||
}
|
||||
}
|
||||
|
||||
// 表操作 Hook
|
||||
export function useTableOperations(showNotification: (type: 'success' | 'error' | 'info', msg: string) => void) {
|
||||
const [tablesMap, setTablesMap] = useState<Map<string, TableInfo[]>>(new Map())
|
||||
const [columnsMap, setColumnsMap] = useState<Map<string, ColumnInfo[]>>(new Map())
|
||||
|
||||
const fetchTables = useCallback(async (connectionId: string, database: string) => {
|
||||
try {
|
||||
const tables = await api.getTables(connectionId, database)
|
||||
setTablesMap(prev => new Map(prev).set(database, tables))
|
||||
} catch (err) {
|
||||
showNotification('error', '获取表列表失败')
|
||||
}
|
||||
}, [showNotification])
|
||||
|
||||
const fetchColumns = useCallback(async (connectionId: string, database: string, table: string) => {
|
||||
try {
|
||||
const cols = await api.getTableColumns(connectionId, database, table)
|
||||
setColumnsMap(prev => new Map(prev).set(table, cols))
|
||||
} catch (err) {
|
||||
// 忽略列获取失败
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
tablesMap,
|
||||
setTablesMap,
|
||||
columnsMap,
|
||||
setColumnsMap,
|
||||
fetchTables,
|
||||
fetchColumns
|
||||
}
|
||||
}
|
||||
|
||||
// Tab 操作 Hook
|
||||
export function useTabOperations() {
|
||||
const [tabs, setTabs] = useState<(QueryTab | TableTab)[]>([])
|
||||
const [activeTab, setActiveTab] = useState<string>('welcome')
|
||||
const [loadingTables, setLoadingTables] = useState<Set<string>>(new Set())
|
||||
|
||||
return {
|
||||
tabs,
|
||||
setTabs,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
loadingTables,
|
||||
setLoadingTables
|
||||
}
|
||||
}
|
||||
|
||||
// 查询操作 Hook
|
||||
export function useQueryOperations(
|
||||
tabs: (QueryTab | TableTab)[],
|
||||
setTabs: React.Dispatch<React.SetStateAction<(QueryTab | TableTab)[]>>,
|
||||
showNotification: (type: 'success' | 'error' | 'info', msg: string) => void
|
||||
) {
|
||||
const runQuery = useCallback(async (tabId: string, connectionId: string, sql: string) => {
|
||||
try {
|
||||
const result = await api.executeQuery(connectionId, sql)
|
||||
setTabs(prev => prev.map(t => {
|
||||
if (t.id === tabId && !('tableName' in t)) {
|
||||
return { ...t, results: result }
|
||||
}
|
||||
return t
|
||||
}))
|
||||
} catch (err) {
|
||||
showNotification('error', '查询失败:' + (err as Error).message)
|
||||
}
|
||||
}, [setTabs, showNotification])
|
||||
|
||||
return { runQuery }
|
||||
}
|
||||
|
||||
// 导入导出 Hook
|
||||
export function useImportExport(
|
||||
connections: Connection[],
|
||||
setConnections: React.Dispatch<React.SetStateAction<Connection[]>>,
|
||||
showNotification: (type: 'success' | 'error' | 'info', msg: string) => void
|
||||
) {
|
||||
const importConnections = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.importConnections()
|
||||
if (result && result.length > 0) {
|
||||
setConnections(prev => {
|
||||
const updated = [...prev, ...result]
|
||||
api.saveConnections(updated)
|
||||
return updated
|
||||
})
|
||||
showNotification('success', `已导入 ${result.length} 个连接`)
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification('error', '导入失败:' + (err as Error).message)
|
||||
}
|
||||
}, [setConnections, showNotification])
|
||||
|
||||
const exportConnections = useCallback(async (format: 'json' | 'ncx') => {
|
||||
try {
|
||||
await api.exportConnections(connections, format)
|
||||
showNotification('success', '导出成功')
|
||||
} catch (err) {
|
||||
showNotification('error', '导出失败:' + (err as Error).message)
|
||||
}
|
||||
}, [connections, showNotification])
|
||||
|
||||
return {
|
||||
importConnections,
|
||||
exportConnections
|
||||
}
|
||||
}
|
||||
|
||||
29
src/types.ts
29
src/types.ts
@ -60,15 +60,24 @@ export interface TableTab {
|
||||
newRows?: any[] // 新增的行数据(尚未保存到数据库)
|
||||
}
|
||||
|
||||
export const DB_INFO: Record<DatabaseType, { name: string; icon: string; color: string; port: number; supported: boolean }> = {
|
||||
mysql: { name: 'MySQL', icon: '🐬', color: '#00758f', port: 3306, supported: true },
|
||||
postgres: { name: 'PostgreSQL', icon: '🐘', color: '#336791', port: 5432, supported: true },
|
||||
sqlite: { name: 'SQLite', icon: '💾', color: '#003b57', port: 0, supported: true },
|
||||
mongodb: { name: 'MongoDB', icon: '🍃', color: '#47a248', port: 27017, supported: true },
|
||||
redis: { name: 'Redis', icon: '⚡', color: '#dc382d', port: 6379, supported: true },
|
||||
sqlserver: { name: 'SQL Server', icon: '📊', color: '#cc2927', port: 1433, supported: true },
|
||||
oracle: { name: 'Oracle', icon: '🔶', color: '#f80000', port: 1521, supported: false },
|
||||
mariadb: { name: 'MariaDB', icon: '🦭', color: '#c0765a', port: 3306, supported: true },
|
||||
snowflake: { name: 'Snowflake', icon: '❄️', color: '#29b5e8', port: 443, supported: false },
|
||||
export const DB_INFO: Record<DatabaseType, {
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
defaultPort: number
|
||||
supported: boolean
|
||||
needsHost: boolean
|
||||
needsAuth: boolean
|
||||
needsFile: boolean
|
||||
}> = {
|
||||
mysql: { name: 'MySQL', icon: '🐬', color: '#00758f', defaultPort: 3306, supported: true, needsHost: true, needsAuth: true, needsFile: false },
|
||||
postgres: { name: 'PostgreSQL', icon: '🐘', color: '#336791', defaultPort: 5432, supported: true, needsHost: true, needsAuth: true, needsFile: false },
|
||||
sqlite: { name: 'SQLite', icon: '💾', color: '#003b57', defaultPort: 0, supported: true, needsHost: false, needsAuth: false, needsFile: true },
|
||||
mongodb: { name: 'MongoDB', icon: '🍃', color: '#47a248', defaultPort: 27017, supported: true, needsHost: true, needsAuth: true, needsFile: false },
|
||||
redis: { name: 'Redis', icon: '⚡', color: '#dc382d', defaultPort: 6379, supported: true, needsHost: true, needsAuth: true, needsFile: false },
|
||||
sqlserver: { name: 'SQL Server', icon: '📊', color: '#cc2927', defaultPort: 1433, supported: true, needsHost: true, needsAuth: true, needsFile: false },
|
||||
oracle: { name: 'Oracle', icon: '🔶', color: '#f80000', defaultPort: 1521, supported: false, needsHost: true, needsAuth: true, needsFile: false },
|
||||
mariadb: { name: 'MariaDB', icon: '🦭', color: '#c0765a', defaultPort: 3306, supported: true, needsHost: true, needsAuth: true, needsFile: false },
|
||||
snowflake: { name: 'Snowflake', icon: '❄️', color: '#29b5e8', defaultPort: 443, supported: false, needsHost: true, needsAuth: true, needsFile: false },
|
||||
}
|
||||
|
||||
|
||||
@ -7,53 +7,119 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Windows Metro 深色主题配色
|
||||
metro: {
|
||||
dark: '#1a1a1a', // 最深背景
|
||||
bg: '#252525', // 主背景
|
||||
surface: '#2d2d2d', // 表面
|
||||
card: '#323232', // 卡片
|
||||
hover: '#3a3a3a', // 悬停
|
||||
border: '#404040', // 边框
|
||||
divider: '#333333', // 分割线
|
||||
// 简约浅色科技主题 - Clean Light
|
||||
light: {
|
||||
// 背景层次 - 从白到浅灰
|
||||
bg: '#ffffff', // 主背景白色
|
||||
surface: '#f8fafc', // 表面浅灰
|
||||
elevated: '#f1f5f9', // 浮起层
|
||||
muted: '#e2e8f0', // 静音背景
|
||||
hover: '#f1f5f9', // 悬停
|
||||
active: '#e2e8f0', // 激活
|
||||
},
|
||||
// Metro 强调色 - Windows 11 风格
|
||||
accent: {
|
||||
blue: '#0078d4', // 主强调色
|
||||
'blue-hover': '#1a86d9',
|
||||
'blue-light': '#60cdff',
|
||||
green: '#0f7b0f', // 成功
|
||||
'green-hover': '#1c9a1c',
|
||||
red: '#c42b1c', // 错误/删除
|
||||
'red-hover': '#d13d2d',
|
||||
orange: '#f7630c', // 警告
|
||||
purple: '#886ce4', // 紫色
|
||||
teal: '#00b294', // 青色
|
||||
yellow: '#ffd800', // 黄色
|
||||
// 边框颜色
|
||||
border: {
|
||||
light: '#f1f5f9',
|
||||
default: '#e2e8f0',
|
||||
strong: '#cbd5e1',
|
||||
},
|
||||
// 文字颜色
|
||||
// 主色调 - 现代蓝
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6', // 主色
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
},
|
||||
// 成功绿
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
},
|
||||
// 警告橙
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
},
|
||||
// 错误红
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
},
|
||||
// 信息紫
|
||||
info: {
|
||||
50: '#faf5ff',
|
||||
100: '#f3e8ff',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
},
|
||||
// 青色 - 数据库/表
|
||||
teal: {
|
||||
50: '#f0fdfa',
|
||||
100: '#ccfbf1',
|
||||
500: '#14b8a6',
|
||||
600: '#0d9488',
|
||||
},
|
||||
// 数据库品牌色
|
||||
db: {
|
||||
mysql: '#00758f',
|
||||
postgresql: '#336791',
|
||||
sqlite: '#003b57',
|
||||
sqlserver: '#cc2927',
|
||||
mongodb: '#47a248',
|
||||
redis: '#dc382d',
|
||||
mariadb: '#003545',
|
||||
},
|
||||
// 文字颜色 - 加深以提高可读性
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: 'rgba(255, 255, 255, 0.7)',
|
||||
tertiary: 'rgba(255, 255, 255, 0.5)',
|
||||
disabled: 'rgba(255, 255, 255, 0.3)',
|
||||
}
|
||||
primary: '#0f172a', // 深色主文字
|
||||
secondary: '#334155', // 次要文字 (加深)
|
||||
tertiary: '#475569', // 第三级文字 (加深)
|
||||
muted: '#64748b', // 静音文字 (加深)
|
||||
disabled: '#94a3b8', // 禁用文字
|
||||
inverse: '#ffffff', // 反色文字
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Segoe UI', 'system-ui', 'sans-serif'],
|
||||
mono: ['Cascadia Code', 'Consolas', 'monospace'],
|
||||
sans: ['Inter', 'SF Pro Display', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'SF Mono', 'Consolas', 'monospace'],
|
||||
},
|
||||
boxShadow: {
|
||||
'metro': '0 2px 4px rgba(0, 0, 0, 0.2)',
|
||||
'metro-lg': '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
'metro-xl': '0 8px 24px rgba(0, 0, 0, 0.4)',
|
||||
// 现代阴影系统
|
||||
'xs': '0 1px 2px rgba(0, 0, 0, 0.03)',
|
||||
'sm': '0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.03)',
|
||||
'md': '0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03)',
|
||||
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.03)',
|
||||
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.02)',
|
||||
// 卡片阴影
|
||||
'card': '0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02)',
|
||||
'card-hover': '0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)',
|
||||
// 按钮阴影
|
||||
'btn': '0 1px 2px rgba(59, 130, 246, 0.1), 0 1px 3px rgba(59, 130, 246, 0.08)',
|
||||
'btn-hover': '0 4px 12px rgba(59, 130, 246, 0.2), 0 2px 4px rgba(59, 130, 246, 0.1)',
|
||||
// 弹窗阴影
|
||||
'modal': '0 25px 50px -12px rgba(0, 0, 0, 0.12)',
|
||||
// 输入框聚焦
|
||||
'focus': '0 0 0 3px rgba(59, 130, 246, 0.15)',
|
||||
},
|
||||
borderRadius: {
|
||||
'4xl': '2rem',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease',
|
||||
'slide-up': 'slideUp 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'slide-down': 'slideDown 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'scale-in': 'scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'slide-up': 'slideUp 0.25s ease-out',
|
||||
'slide-down': 'slideDown 0.25s ease-out',
|
||||
'scale-in': 'scaleIn 0.2s ease-out',
|
||||
'spin-slow': 'spin 1.5s linear infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
@ -61,24 +127,17 @@ export default {
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(8px)' },
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
slideDown: {
|
||||
'0%': { opacity: '0', transform: 'translateY(-8px)' },
|
||||
'0%': { opacity: '0', transform: 'translateY(-10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||
'0%': { opacity: '0', transform: 'scale(0.96)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
pulseSoft: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
'metro': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user