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:
Ethanfly 2025-12-31 15:58:26 +08:00
parent 2f907369a0
commit bca7eff0cd
13 changed files with 2295 additions and 2793 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,353 +1,426 @@
import { useState, useEffect } from 'react' import { X, Database, Check, AlertCircle, ChevronDown, ChevronRight, Shield, Globe, Server, Key, User, Folder, FileText } from 'lucide-react'
import { X, Loader2, Shield, FolderOpen } from 'lucide-react' import { Connection, DB_INFO, DatabaseType } from '../types'
import { Connection, DatabaseType, DB_INFO } from '../types' import { useState, useEffect, useRef } from 'react'
import api from '../lib/electron-api' import api from '../lib/electron-api'
interface Props { interface Props {
connection: Connection | null isOpen: boolean
defaultType?: DatabaseType editingConnection?: Connection | null
onSave: (conn: Connection) => void initialType?: DatabaseType
onClose: () => void onClose: () => void
onSave: (conn: Omit<Connection, 'id'> & { id?: string }) => void
} }
export default function ConnectionModal({ connection, defaultType, onSave, onClose }: Props) { export default function ConnectionModal({ isOpen, editingConnection, initialType, onClose, onSave }: Props) {
const initialType = defaultType || 'mysql' const [selectedType, setSelectedType] = useState<DatabaseType>(editingConnection?.type || initialType || 'mysql')
const initialPort = DB_INFO[initialType]?.port || 3306 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 [form, setForm] = useState<Connection>({ const nameInputRef = useRef<HTMLInputElement>(null)
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)
useEffect(() => { useEffect(() => {
if (connection) { if (isOpen) {
setForm(connection) const timer = setTimeout(() => nameInputRef.current?.focus(), 100)
} else { return () => clearTimeout(timer)
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 || ''
}))
} }
}, [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 handleTypeChange = (type: DatabaseType) => {
const info = DB_INFO[type] setSelectedType(type)
setForm(prev => ({ ...prev, type, port: info?.port || prev.port })) setPort(DB_INFO[type].defaultPort)
setMessage(null)
} }
const handleTest = async () => { const handleTest = async () => {
setTesting(true) try {
setMessage(null) 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 result = await api.testConnection(form) const buildConnection = (): Omit<Connection, 'id'> & { id?: string } => {
setMessage({ const info = DB_INFO[selectedType]
text: result?.message || '测试失败', return {
type: result?.success ? 'success' : 'error' ...(editingConnection?.id ? { id: editingConnection.id } : {}),
}) type: selectedType,
setTesting(false) 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 = () => { const handleSave = () => {
if (!form.name.trim()) { if (!name.trim()) {
setMessage({ text: '请输入连接名称', type: 'error' }) setMessage({ type: 'error', text: '请输入连接名称' })
setTimeout(() => setMessage(null), 3000)
return return
} }
onSave(form) onSave(buildConnection())
onClose()
} }
return ( const handleSelectFile = async () => {
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"> const filePath = await api.selectFile([{ name: 'SQLite', extensions: ['db', 'sqlite', 'sqlite3'] }])
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} /> if (filePath) setFile(filePath)
}
{/* Metro 风格弹窗 */} const handleSelectKeyFile = async () => {
<div className="relative w-[560px] max-h-[90vh] bg-metro-bg flex flex-col overflow-hidden shadow-metro-xl animate-slide-up"> const filePath = await api.selectFile([{ name: 'PEM', extensions: ['pem', 'key', 'ppk'] }])
{/* 标题栏 */} if (filePath) setSshKeyFile(filePath)
<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"> if (!isOpen) return null
<X size={20} />
const info = DB_INFO[selectedType]
const isEditing = !!editingConnection
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 animate-fade-in backdrop-blur-sm" onClick={onClose}>
<div className="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> </button>
</div> </div>
{/* 内容 */} {/* 内容 */}
<div className="flex-1 overflow-y-auto p-5 space-y-5"> <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>
<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> <div>
<label className="block text-sm text-text-secondary mb-2 font-medium"></label> <label className="block text-xs font-medium text-text-secondary mb-2"></label>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<input {(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][])
type="text" .filter(([, i]) => i.supported)
value={form.database} .map(([type, i]) => (
onChange={(e) => setForm(prev => ({ ...prev, database: e.target.value }))} <button
placeholder="选择或输入 .db 文件路径" key={type}
className="flex-1 h-10 px-4 bg-metro-surface border-2 border-transparent onClick={() => handleTypeChange(type)}
focus:border-accent-blue text-sm transition-all rounded-sm" className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all
/> ${selectedType === type
<button ? 'border-primary-500 bg-primary-50 text-primary-700 shadow-focus'
onClick={async () => { : 'border-border-default hover:border-border-strong text-text-primary hover:bg-light-hover'}`}
const result = await api.selectFile(['db', 'sqlite', 'sqlite3']) >
if (result?.path) { <span className="text-lg">{i.icon}</span>
setForm(prev => ({ ...prev, database: result.path })) <span className="font-medium">{i.name}</span>
} </button>
}} ))}
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>
</div> </div>
<p className="text-xs text-text-disabled mt-2"></p>
</div> </div>
) : (
<> {/* 连接名称 */}
{/* 主机和端口 */} <div>
<div className="grid grid-cols-4 gap-4"> <label className="block text-xs font-medium text-text-secondary mb-2">
<div className="col-span-3"> <User size={12} className="inline mr-1" />
<label className="block text-sm text-text-secondary mb-2 font-medium"></label>
</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 <input
type="text" type="text"
value={form.host} value={file}
onChange={(e) => setForm(prev => ({ ...prev, host: e.target.value }))} onChange={(e) => setFile(e.target.value)}
placeholder="localhost" placeholder="选择或输入 .db 文件路径"
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent 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"
focus:border-accent-blue text-sm transition-all rounded-sm"
/> />
</div> <button
<div> onClick={handleSelectFile}
<label className="block text-sm text-text-secondary mb-2 font-medium"></label> 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"
<input >
type="number" <Folder size={14} />
value={form.port}
onChange={(e) => setForm(prev => ({ ...prev, port: parseInt(e.target.value) || 0 }))} </button>
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>
</div> </div>
</div> </div>
)} )}
</div>
{/* 消息 */} {/* 主机和端口 */}
{message && ( {info.needsHost && (
<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'}`}> <div className="grid grid-cols-3 gap-3">
{message.text} <div className="col-span-2">
</div> <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>
{/* 底部按钮 */} {/* 底部按钮 */}
<div className="h-16 bg-metro-surface flex items-center justify-end gap-3 px-5 border-t border-metro-border/50"> <div className="h-16 flex items-center justify-end gap-3 px-5 border-t border-border-default bg-light-surface">
<button <button onClick={handleTest}
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">
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" />}
</button> </button>
<button <button onClick={onClose}
onClick={handleSave} 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">
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> </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> </div>
</div> </div>

View File

@ -1,10 +1,12 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { X, Database } from 'lucide-react' import { X, Database, Settings } from 'lucide-react'
import api from '../lib/electron-api'
interface Props { interface Props {
isOpen: boolean isOpen: boolean
connectionId: string | null
onClose: () => void onClose: () => void
onSubmit: (name: string, charset: string, collation: string) => void onCreated: () => void
} }
// MySQL 字符集和排序规则 // MySQL 字符集和排序规则
@ -16,10 +18,21 @@ const CHARSETS = [
{ name: 'gb2312', collations: ['gb2312_chinese_ci', 'gb2312_bin'] }, { 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 [name, setName] = useState('')
const [charset, setCharset] = useState('utf8mb4') const [charset, setCharset] = useState('utf8mb4')
const [collation, setCollation] = useState('utf8mb4_general_ci') 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 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() e.preventDefault()
if (name.trim()) { if (!name.trim() || !connectionId) return
onSubmit(name.trim(), charset, collation)
setName('') setLoading(true)
setCharset('utf8mb4') setError('')
setCollation('utf8mb4_general_ci')
try {
await api.createDatabase(connectionId, name.trim(), charset, collation)
onCreated()
onClose()
} catch (err) {
setError((err as Error).message)
} finally {
setLoading(false)
} }
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> <div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<div className="bg-metro-card border border-metro-border w-[420px] shadow-metro-lg 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 justify-between px-5 py-4 border-b border-border-default">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Database size={18} className="text-accent-blue" /> <div className="w-9 h-9 rounded-xl bg-teal-50 flex items-center justify-center">
<span className="font-medium"></span> <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> </div>
<button <button
onClick={onClose} 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> </button>
</div> </div>
{/* 表单 */} {/* 表单 */}
<form onSubmit={handleSubmit} className="p-4 space-y-4"> <form onSubmit={handleSubmit} className="p-5 space-y-4">
<div> <div>
<label className="block text-sm text-text-secondary mb-1.5"> <label className="flex items-center gap-2 text-sm text-text-secondary mb-2 font-medium">
<span className="text-accent-red">*</span> <Database size={14} className="text-primary-500" />
<span className="text-danger-500">*</span>
</label> </label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="输入数据库名称" placeholder="输入数据库名称"
className="w-full h-9 px-3 bg-metro-surface border border-metro-border text-sm className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg
focus:border-accent-blue focus:outline-none transition-colors" focus:border-primary-500 focus:shadow-focus text-sm transition-all"
autoFocus autoFocus
/> />
</div> </div>
<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 <select
value={charset} value={charset}
onChange={(e) => handleCharsetChange(e.target.value)} onChange={(e) => handleCharsetChange(e.target.value)}
className="w-full h-9 px-3 bg-metro-surface border border-metro-border text-sm className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg
focus:border-accent-blue focus:outline-none transition-colors" focus:border-primary-500 text-sm transition-all cursor-pointer"
> >
{CHARSETS.map(cs => ( {CHARSETS.map(cs => (
<option key={cs.name} value={cs.name}>{cs.name}</option> <option key={cs.name} value={cs.name}>{cs.name}</option>
@ -93,12 +125,12 @@ export default function CreateDatabaseModal({ isOpen, onClose, onSubmit }: Props
</div> </div>
<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 <select
value={collation} value={collation}
onChange={(e) => setCollation(e.target.value)} onChange={(e) => setCollation(e.target.value)}
className="w-full h-9 px-3 bg-metro-surface border border-metro-border text-sm className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg
focus:border-accent-blue focus:outline-none transition-colors" focus:border-primary-500 text-sm transition-all cursor-pointer"
> >
{collations.map(col => ( {collations.map(col => (
<option key={col} value={col}>{col}</option> <option key={col} value={col}>{col}</option>
@ -106,22 +138,30 @@ export default function CreateDatabaseModal({ isOpen, onClose, onSubmit }: Props
</select> </select>
</div> </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"> <div className="flex justify-end gap-2 pt-2">
<button <button
type="button" type="button"
onClick={onClose} 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>
<button <button
type="submit" type="submit"
disabled={!name.trim()} disabled={!name.trim() || loading}
className="px-4 py-2 text-sm bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-50 className="px-4 py-2 text-sm bg-primary-500 hover:bg-primary-600 text-white
disabled:cursor-not-allowed transition-colors" disabled:opacity-50 disabled:cursor-not-allowed
rounded-lg transition-all font-medium shadow-btn hover:shadow-btn-hover"
> >
{loading ? '创建中...' : '创建数据库'}
</button> </button>
</div> </div>
</form> </form>
@ -129,4 +169,3 @@ export default function CreateDatabaseModal({ isOpen, onClose, onSubmit }: Props
</div> </div>
) )
} }

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { X, Table2, Plus, Trash2, Key, ArrowUp, ArrowDown } from 'lucide-react' import { X, Table2, Plus, Trash2, Key, ArrowUp, ArrowDown, Check } from 'lucide-react'
import api from '../lib/electron-api'
interface ColumnDef { interface ColumnDef {
id: string id: string
@ -15,9 +16,10 @@ interface ColumnDef {
interface Props { interface Props {
isOpen: boolean isOpen: boolean
database: string connectionId: string | null
database: string | null
onClose: () => void onClose: () => void
onSubmit: (tableName: string, columns: ColumnDef[]) => void onCreated: () => void
} }
// 常用数据类型 // 常用数据类型
@ -41,11 +43,21 @@ const DEFAULT_COLUMN: Omit<ColumnDef, 'id'> = {
comment: '', 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 [tableName, setTableName] = useState('')
const [columns, setColumns] = useState<ColumnDef[]>([ const [columns, setColumns] = useState<ColumnDef[]>([
{ ...DEFAULT_COLUMN, id: crypto.randomUUID(), name: 'id', primaryKey: true, autoIncrement: true, nullable: false } { ...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 if (!isOpen) return null
@ -63,11 +75,9 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
setColumns(columns.map(col => { setColumns(columns.map(col => {
if (col.id !== id) return col if (col.id !== id) return col
const updated = { ...col, [field]: value } const updated = { ...col, [field]: value }
// 主键不能为空
if (field === 'primaryKey' && value) { if (field === 'primaryKey' && value) {
updated.nullable = false updated.nullable = false
} }
// 自增必须是主键
if (field === 'autoIncrement' && value) { if (field === 'autoIncrement' && value) {
updated.primaryKey = true updated.primaryKey = true
updated.nullable = false updated.nullable = false
@ -86,63 +96,81 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
setColumns(newColumns) setColumns(newColumns)
} }
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (tableName.trim() && columns.some(c => c.name.trim())) { if (!tableName.trim() || !columns.some(c => c.name.trim()) || !connectionId || !database) return
onSubmit(tableName.trim(), columns.filter(c => c.name.trim()))
setTableName('') setLoading(true)
setColumns([{ ...DEFAULT_COLUMN, id: crypto.randomUUID(), name: 'id', primaryKey: true, autoIncrement: true, nullable: false }]) 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) => { const needsLength = (type: string) => {
return ['VARCHAR', 'CHAR', 'DECIMAL', 'FLOAT', 'DOUBLE', 'BINARY', 'VARBINARY'].includes(type) return ['VARCHAR', 'CHAR', 'DECIMAL', 'FLOAT', 'DOUBLE', 'BINARY', 'VARBINARY'].includes(type)
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> <div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<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="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 justify-between px-5 py-4 border-b border-border-default flex-shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Table2 size={18} className="text-accent-orange" /> <div className="w-9 h-9 rounded-xl bg-warning-50 flex items-center justify-center">
<span className="font-medium"> - {database}</span> <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> </div>
<button <button
onClick={onClose} 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> </button>
</div> </div>
{/* 表单 */} {/* 表单 */}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0"> <form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
{/* 表名 */} {/* 表名 */}
<div className="p-4 border-b border-metro-border flex-shrink-0"> <div className="p-5 border-b border-border-default flex-shrink-0">
<label className="block text-sm text-text-secondary mb-1.5"> <label className="block text-sm text-text-secondary mb-2 font-medium">
<span className="text-accent-red">*</span> <span className="text-danger-500">*</span>
</label> </label>
<input <input
type="text" type="text"
value={tableName} value={tableName}
onChange={(e) => setTableName(e.target.value)} onChange={(e) => setTableName(e.target.value)}
placeholder="输入表名称" placeholder="输入表名称"
className="w-64 h-9 px-3 bg-metro-surface border border-metro-border text-sm className="w-64 h-10 px-3 bg-light-surface border border-border-default rounded-lg
focus:border-accent-blue focus:outline-none transition-colors" focus:border-primary-500 focus:shadow-focus text-sm transition-all"
autoFocus autoFocus
/> />
</div> </div>
{/* 字段列表 */} {/* 字段列表 */}
<div className="flex-1 overflow-auto p-4"> <div className="flex-1 overflow-auto p-5 scrollbar-thin">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-4">
<span className="text-sm text-text-secondary"></span> <span className="text-sm text-text-secondary font-medium"></span>
<button <button
type="button" type="button"
onClick={addColumn} 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} /> <Plus size={14} />
@ -150,40 +178,40 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
</div> </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-8"></div>
<div className="w-32"></div> <div className="w-28"></div>
<div className="w-28"></div> <div className="w-24"></div>
<div className="w-16"></div> <div className="w-14"></div>
<div className="w-12 text-center"></div> <div className="w-10 text-center"></div>
<div className="w-12 text-center"></div> <div className="w-10 text-center"></div>
<div className="w-12 text-center"></div> <div className="w-10 text-center"></div>
<div className="w-24"></div> <div className="w-20"></div>
<div className="flex-1"></div> <div className="flex-1"></div>
<div className="w-16"></div> <div className="w-10"></div>
</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) => ( {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"> <div className="w-8 flex flex-col gap-0.5">
<button <button
type="button" type="button"
onClick={() => moveColumn(index, 'up')} onClick={() => moveColumn(index, 'up')}
disabled={index === 0} 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>
<button <button
type="button" type="button"
onClick={() => moveColumn(index, 'down')} onClick={() => moveColumn(index, 'down')}
disabled={index === columns.length - 1} 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> </button>
</div> </div>
@ -193,16 +221,16 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
value={col.name} value={col.name}
onChange={(e) => updateColumn(col.id, 'name', e.target.value)} onChange={(e) => updateColumn(col.id, 'name', e.target.value)}
placeholder="字段名" placeholder="字段名"
className="w-32 h-7 px-2 bg-metro-surface border border-metro-border text-xs className="w-28 h-8 px-2 bg-white border border-border-default rounded text-xs
focus:border-accent-blue focus:outline-none transition-colors" focus:border-primary-500 focus:outline-none transition-colors font-mono"
/> />
{/* 类型 */} {/* 类型 */}
<select <select
value={col.type} value={col.type}
onChange={(e) => updateColumn(col.id, 'type', e.target.value)} 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 className="w-24 h-8 px-2 bg-white border border-border-default rounded text-xs
focus:border-accent-blue focus:outline-none transition-colors" focus:border-primary-500 focus:outline-none transition-colors cursor-pointer"
> >
{DATA_TYPES.map(group => ( {DATA_TYPES.map(group => (
<optgroup key={group.group} label={group.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)} onChange={(e) => updateColumn(col.id, 'length', e.target.value)}
placeholder={needsLength(col.type) ? '长度' : '-'} placeholder={needsLength(col.type) ? '长度' : '-'}
disabled={!needsLength(col.type)} disabled={!needsLength(col.type)}
className="w-16 h-7 px-2 bg-metro-surface border border-metro-border text-xs className="w-14 h-8 px-2 bg-white border border-border-default rounded text-xs text-center
focus:border-accent-blue focus:outline-none transition-colors focus:border-primary-500 focus:outline-none transition-colors
disabled:opacity-50 disabled:cursor-not-allowed" 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 <button
type="button" type="button"
onClick={() => updateColumn(col.id, 'primaryKey', !col.primaryKey)} 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} /> <Key size={12} />
</button> </button>
</div> </div>
{/* 自增 */} {/* 自增 */}
<div className="w-12 flex justify-center"> <div className="w-10 flex justify-center">
<input <label className="cursor-pointer">
type="checkbox" <div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all
checked={col.autoIncrement} ${col.autoIncrement
onChange={(e) => updateColumn(col.id, 'autoIncrement', e.target.checked)} ? 'bg-primary-500 border-primary-500'
className="w-4 h-4 accent-accent-blue" : '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>
{/* 可空 */} {/* 可空 */}
<div className="w-12 flex justify-center"> <div className="w-10 flex justify-center">
<input <label className={`cursor-pointer ${col.primaryKey ? 'opacity-40 cursor-not-allowed' : ''}`}>
type="checkbox" <div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all
checked={col.nullable} ${col.nullable
onChange={(e) => updateColumn(col.id, 'nullable', e.target.checked)} ? 'bg-success-500 border-success-500'
disabled={col.primaryKey} : 'border-border-strong hover:border-success-300'}`}>
className="w-4 h-4 accent-accent-blue disabled:opacity-50" {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> </div>
{/* 默认值 */} {/* 默认值 */}
@ -263,8 +311,8 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
value={col.defaultValue} value={col.defaultValue}
onChange={(e) => updateColumn(col.id, 'defaultValue', e.target.value)} onChange={(e) => updateColumn(col.id, 'defaultValue', e.target.value)}
placeholder="默认值" placeholder="默认值"
className="w-24 h-7 px-2 bg-metro-surface border border-metro-border text-xs className="w-20 h-8 px-2 bg-white border border-border-default rounded text-xs
focus:border-accent-blue focus:outline-none transition-colors" focus:border-primary-500 focus:outline-none transition-colors"
/> />
{/* 备注 */} {/* 备注 */}
@ -273,18 +321,18 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
value={col.comment} value={col.comment}
onChange={(e) => updateColumn(col.id, 'comment', e.target.value)} onChange={(e) => updateColumn(col.id, 'comment', e.target.value)}
placeholder="备注" placeholder="备注"
className="flex-1 h-7 px-2 bg-metro-surface border border-metro-border text-xs className="flex-1 h-8 px-2 bg-white border border-border-default rounded text-xs
focus:border-accent-blue focus:outline-none transition-colors" focus:border-primary-500 focus:outline-none transition-colors"
/> />
{/* 删除按钮 */} {/* 删除按钮 */}
<div className="w-16 flex justify-end"> <div className="w-10 flex justify-center">
<button <button
type="button" type="button"
onClick={() => removeColumn(col.id)} onClick={() => removeColumn(col.id)}
disabled={columns.length === 1} disabled={columns.length === 1}
className="p-1.5 text-text-disabled hover:text-accent-red hover:bg-metro-hover className="p-1.5 text-text-muted hover:text-danger-500 hover:bg-danger-50
rounded-sm transition-colors disabled:opacity-30 disabled:cursor-not-allowed" rounded transition-all disabled:opacity-30 disabled:cursor-not-allowed"
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
@ -294,22 +342,30 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
</div> </div>
</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 <button
type="button" type="button"
onClick={onClose} 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>
<button <button
type="submit" type="submit"
disabled={!tableName.trim() || !columns.some(c => c.name.trim())} disabled={!tableName.trim() || !columns.some(c => c.name.trim()) || loading}
className="px-4 py-2 text-sm bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-50 className="px-4 py-2 text-sm bg-primary-500 hover:bg-primary-600 text-white
disabled:cursor-not-allowed transition-colors" disabled:opacity-50 disabled:cursor-not-allowed
rounded-lg transition-all font-medium shadow-btn hover:shadow-btn-hover"
> >
{loading ? '创建中...' : '创建表'}
</button> </button>
</div> </div>
</form> </form>
@ -317,4 +373,3 @@ export default function CreateTableModal({ isOpen, database, onClose, onSubmit }
</div> </div>
) )
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { X } from 'lucide-react' import { X, Check } from 'lucide-react'
interface Props { interface Props {
isOpen: boolean isOpen: boolean
@ -9,11 +9,10 @@ interface Props {
defaultValue?: string defaultValue?: string
confirmText?: string confirmText?: string
onClose: () => void onClose: () => void
onSubmit: (value: string) => void onConfirm: (value: string) => void
icon?: React.ReactNode icon?: React.ReactNode
// 用于复制表的额外选项
showDataOption?: boolean showDataOption?: boolean
onSubmitWithData?: (value: string, withData: boolean) => void onConfirmWithData?: (value: string, withData: boolean) => void
} }
export default function InputDialog({ export default function InputDialog({
@ -24,10 +23,10 @@ export default function InputDialog({
defaultValue = '', defaultValue = '',
confirmText = '确定', confirmText = '确定',
onClose, onClose,
onSubmit, onConfirm,
icon, icon,
showDataOption, showDataOption,
onSubmitWithData, onConfirmWithData,
}: Props) { }: Props) {
const [value, setValue] = useState(defaultValue) const [value, setValue] = useState(defaultValue)
const [withData, setWithData] = useState(false) const [withData, setWithData] = useState(false)
@ -41,61 +40,70 @@ export default function InputDialog({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (value.trim()) { if (value.trim()) {
if (showDataOption && onSubmitWithData) { if (showDataOption && onConfirmWithData) {
onSubmitWithData(value.trim(), withData) onConfirmWithData(value.trim(), withData)
} else { } else {
onSubmit(value.trim()) onConfirm(value.trim())
} }
} }
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> <div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<div className="bg-metro-card border border-metro-border w-[380px] shadow-metro-lg 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 justify-between px-5 py-4 border-b border-border-default">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
{icon} {icon && (
<span className="font-medium">{title}</span> <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> </div>
<button <button
onClick={onClose} 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> </button>
</div> </div>
{/* 表单 */} {/* 表单 */}
<form onSubmit={handleSubmit} className="p-4 space-y-4"> <form onSubmit={handleSubmit} className="p-5 space-y-4">
<div> <div>
<label className="block text-sm text-text-secondary mb-1.5"> <label className="block text-sm text-text-secondary mb-2 font-medium">
{label} <span className="text-accent-red">*</span> {label} <span className="text-danger-500">*</span>
</label> </label>
<input <input
type="text" type="text"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
className="w-full h-9 px-3 bg-metro-surface border border-metro-border text-sm className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg
focus:border-accent-blue focus:outline-none transition-colors" focus:border-primary-500 focus:shadow-focus text-sm transition-all"
autoFocus autoFocus
/> />
</div> </div>
{showDataOption && ( {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 <input
type="checkbox" type="checkbox"
id="withData"
checked={withData} checked={withData}
onChange={(e) => setWithData(e.target.checked)} 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"> <span className="text-sm text-text-secondary"></span>
</label>
</label>
</div>
)} )}
{/* 按钮 */} {/* 按钮 */}
@ -103,15 +111,17 @@ export default function InputDialog({
<button <button
type="button" type="button"
onClick={onClose} 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>
<button <button
type="submit" type="submit"
disabled={!value.trim()} disabled={!value.trim()}
className="px-4 py-2 text-sm bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-50 className="px-4 py-2 text-sm bg-primary-500 hover:bg-primary-600 text-white
disabled:cursor-not-allowed transition-colors" disabled:opacity-50 disabled:cursor-not-allowed
rounded-lg transition-all font-medium shadow-btn hover:shadow-btn-hover"
> >
{confirmText} {confirmText}
</button> </button>
@ -121,4 +131,3 @@ export default function InputDialog({
</div> </div>
) )
} }

View File

@ -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 { 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 { format } from 'sql-formatter'
import api from '../lib/electron-api' import api from '../lib/electron-api'
import VirtualDataTable from './VirtualDataTable' import VirtualDataTable from './VirtualDataTable'
// 懒加载 Monaco Editor 以提升首次加载性能
const SqlEditor = lazy(() => import('./SqlEditor')) const SqlEditor = lazy(() => import('./SqlEditor'))
// 编辑器加载占位组件
const EditorLoading = memo(() => ( 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"> <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> <span className="text-sm text-text-tertiary">...</span>
</div> </div>
</div> </div>
@ -41,13 +39,12 @@ interface Props {
onSaveTableChanges?: (tabId: string) => Promise<void> onSaveTableChanges?: (tabId: string) => Promise<void>
onDiscardTableChanges?: (tabId: string) => void onDiscardTableChanges?: (tabId: string) => void
onRefreshTable?: (tabId: string) => void onRefreshTable?: (tabId: string) => void
onAddTableRow?: (tabId: string) => void // 新增行 onAddTableRow?: (tabId: string) => void
onUpdateNewRow?: (tabId: string, rowIndex: number, colName: string, value: any) => void // 更新新增行 onUpdateNewRow?: (tabId: string, rowIndex: number, colName: string, value: any) => void
onDeleteNewRow?: (tabId: string, rowIndex: number) => void // 删除新增行 onDeleteNewRow?: (tabId: string, rowIndex: number) => void
loadingTables?: Set<string> // 正在加载的表标签ID loadingTables?: Set<string>
} }
// 主内容组件
const MainContent = memo(function MainContent({ const MainContent = memo(function MainContent({
tabs, tabs,
activeTab, activeTab,
@ -74,14 +71,11 @@ const MainContent = memo(function MainContent({
onDeleteNewRow, onDeleteNewRow,
loadingTables, loadingTables,
}: Props) { }: Props) {
// 快捷键处理
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'w') { if (e.ctrlKey && e.key === 'w') {
e.preventDefault() e.preventDefault()
if (activeTab !== 'welcome') { if (activeTab !== 'welcome') onCloseTab(activeTab)
onCloseTab(activeTab)
}
} }
if (e.ctrlKey && e.key === 's') { if (e.ctrlKey && e.key === 's') {
const tab = tabs.find(t => t.id === activeTab) const tab = tabs.find(t => t.id === activeTab)
@ -91,7 +85,6 @@ const MainContent = memo(function MainContent({
} }
} }
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [activeTab, tabs, onCloseTab, onSaveTableChanges]) }, [activeTab, tabs, onCloseTab, onSaveTableChanges])
@ -105,62 +98,60 @@ const MainContent = memo(function MainContent({
const getTabIcon = (tab: Tab) => { const getTabIcon = (tab: Tab) => {
if ('tableName' in tab) { if ('tableName' in tab) {
return <Table2 size={12} className="text-accent-orange" /> return <Table2 size={13} className="text-warning-500" />
} }
return null return null
} }
return ( return (
<div className="flex-1 flex flex-col bg-metro-dark"> <div className="flex-1 flex flex-col bg-white">
{/* Metro 风格标签栏 */} {/* 标签栏 */}
<div className="h-10 bg-metro-bg flex items-stretch px-1 border-b border-metro-border/50 overflow-x-auto"> <div className="h-10 bg-light-surface flex items-stretch px-1 border-b border-border-default overflow-x-auto scrollbar-thin">
<button <button
onClick={() => onTabChange('welcome')} 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' ${activeTab === 'welcome'
? 'bg-metro-dark text-white font-medium' ? 'text-text-primary font-medium'
: 'text-text-secondary hover:text-white hover:bg-metro-hover'}`} : 'text-text-tertiary hover:text-text-secondary hover:bg-light-hover'}`}
> >
<Database size={14} className={activeTab === 'welcome' ? 'text-primary-500' : ''} />
{activeTab === 'welcome' && ( {activeTab === 'welcome' && <span className="tab-indicator" />}
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-blue" />
)}
</button> </button>
<div className="w-px h-5 bg-border-light self-center mx-1" />
{tabs.map(tab => ( {tabs.map(tab => (
<div <div
key={tab.id} 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 ${activeTab === tab.id
? 'bg-metro-dark text-white font-medium' ? 'text-text-primary font-medium'
: 'text-text-secondary hover:text-white hover:bg-metro-hover'}`} : '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)}
{getTabIcon(tab)} <span className="max-w-[120px] truncate">{getTabTitle(tab)}</span>
<span className="max-w-[120px] truncate">{getTabTitle(tab)}</span>
</button>
<button <button
onClick={() => onCloseTab(tab.id)} onClick={(e) => { e.stopPropagation(); 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" className="opacity-0 group-hover:opacity-100 hover:text-danger-500 p-0.5 rounded transition-all"
> >
<X size={14} /> <X size={13} />
</button> </button>
{activeTab === tab.id && ( {activeTab === tab.id && <span className="tab-indicator" />}
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-blue" />
)}
</div> </div>
))} ))}
<button <button
onClick={onNewQuery} onClick={onNewQuery}
className="w-10 flex items-center justify-center text-text-tertiary hover:text-white hover:bg-metro-hover shrink-0 transition-colors" 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="新建查询 (Ctrl+Q)" title="新建查询"
> >
<Plus size={18} /> <Plus size={16} />
</button> </button>
</div> </div>
{/* 内容区 */} {/* 内容区 */}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{activeTab === 'welcome' ? ( {activeTab === 'welcome' ? (
<WelcomeScreen onNewQuery={onNewQuery} onNewConnectionWithType={onNewConnectionWithType} /> <WelcomeScreen onNewQuery={onNewQuery} onNewConnectionWithType={onNewConnectionWithType} />
@ -198,7 +189,7 @@ const MainContent = memo(function MainContent({
) )
}) })
// 欢迎屏幕组件 // 欢迎屏幕
const WelcomeScreen = memo(function WelcomeScreen({ const WelcomeScreen = memo(function WelcomeScreen({
onNewQuery, onNewQuery,
onNewConnectionWithType onNewConnectionWithType
@ -207,84 +198,81 @@ const WelcomeScreen = memo(function WelcomeScreen({
onNewConnectionWithType?: (type: DatabaseType) => void onNewConnectionWithType?: (type: DatabaseType) => void
}) { }) {
return ( 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="h-full flex flex-col items-center justify-center bg-gradient-to-b from-white via-light-surface to-light-elevated">
{/* 背景装饰 */} {/* Logo */}
<div className="absolute inset-0 pointer-events-none"> <div className="flex items-center gap-4 mb-4">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent-blue/5 rounded-full blur-3xl" /> <div className="w-16 h-16 rounded-2xl bg-primary-500 flex items-center justify-center shadow-btn">
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-accent-purple/5 rounded-full blur-3xl" /> <Database size={32} className="text-white" />
</div>
</div> </div>
{/* Logo 区域 */} <h1 className="text-4xl font-bold text-text-primary mb-2">EasySQL</h1>
<div className="flex items-center gap-4 mb-3 relative z-10"> <p className="text-text-tertiary text-lg mb-8"></p>
<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>
<button <button
onClick={onNewQuery} onClick={onNewQuery}
className="px-10 py-3.5 bg-accent-blue hover:bg-accent-blue-hover text-base font-medium className="px-8 py-3 bg-primary-500 hover:bg-primary-600 text-white text-base font-medium
transition-all duration-200 shadow-metro hover:shadow-metro-lg relative z-10 rounded-xl shadow-btn hover:shadow-btn-hover transition-all flex items-center gap-2"
hover:translate-y-[-2px]"
> >
<Zap size={18} />
</button> </button>
{/* 数据库磁贴 */} {/* 快捷键 */}
<p className="mt-14 text-text-disabled text-sm tracking-wide relative z-10"></p> <div className="flex items-center gap-6 mt-6 text-xs text-text-muted">
<div className="mt-5 grid grid-cols-5 gap-2 relative z-10"> <span className="flex items-center gap-2">
{(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][]).slice(0, 5).map(([key, info]) => ( <kbd className="px-2 py-1 bg-light-elevated rounded border border-border-default font-mono">Ctrl+Q</kbd>
<button
key={key} </span>
onClick={() => info.supported && onNewConnectionWithType?.(key)} <span className="flex items-center gap-2">
className={`metro-tile w-24 h-24 flex flex-col items-center justify-center shadow-metro relative <kbd className="px-2 py-1 bg-light-elevated rounded border border-border-default font-mono">Ctrl+Enter</kbd>
${info.supported ? 'cursor-pointer' : 'cursor-not-allowed'}`}
style={{ </span>
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> </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 <div className="mt-12">
key={key} <p className="text-center text-text-muted text-sm mb-4 font-medium"></p>
onClick={() => info.supported && onNewConnectionWithType?.(key)}
className={`metro-tile w-24 h-24 flex flex-col items-center justify-center shadow-metro relative <div className="flex gap-3 justify-center mb-3">
${info.supported ? 'cursor-pointer' : 'cursor-not-allowed'}`} {(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][]).slice(0, 5).map(([key, info]) => (
style={{ <button
backgroundColor: info.color, key={key}
opacity: info.supported ? 1 : 0.4, onClick={() => info.supported && onNewConnectionWithType?.(key)}
filter: info.supported ? 'none' : 'grayscale(50%)' className={`db-tile w-20 h-20 flex flex-col items-center justify-center
}} ${info.supported ? '' : 'cursor-not-allowed opacity-40'}`}
title={info.supported ? `创建 ${info.name} 连接` : `${info.name} - 即将支持`} style={{ background: info.color }}
disabled={!info.supported} 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> <span className="text-3xl mb-1">{info.icon}</span>
{!info.supported && ( <span className="text-[10px] font-medium text-white/90">{info.name}</span>
<span className="absolute bottom-1 text-[10px] text-white/60"></span> </button>
)} ))}
</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>
</div> </div>
) )
}) })
// 表格查看器组件 // 表格查看器
const TableViewer = memo(function TableViewer({ const TableViewer = memo(function TableViewer({
tab, tab,
isLoading, 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 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 primaryKeyCol = tab.columns.find(c => c.key === 'PRI')?.name || tab.columns[0]?.name
// 计算修改过的单元格
const modifiedCells = new Set<string>() const modifiedCells = new Set<string>()
tab.pendingChanges?.forEach((changes, rowKey) => { tab.pendingChanges?.forEach((changes, rowKey) => {
const rowIndex = parseInt(rowKey) const rowIndex = parseInt(rowKey)
@ -327,10 +314,10 @@ const TableViewer = memo(function TableViewer({
}) })
}) })
// 标记新增行的单元格
const newRowCount = tab.newRows?.length || 0 const newRowCount = tab.newRows?.length || 0
const existingDataCount = tab.data.filter((_, i) => !tab.deletedRows?.has(i)).length
if (newRowCount > 0) { if (newRowCount > 0) {
const existingDataCount = tab.data.filter((_, i) => !tab.deletedRows?.has(i)).length
for (let i = 0; i < newRowCount; i++) { for (let i = 0; i < newRowCount; i++) {
const rowIndex = existingDataCount + i const rowIndex = existingDataCount + i
tab.columns.forEach(col => { 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 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 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 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 ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}> <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="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 min-w-0"> <div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-lg border border-border-default">
<Table2 size={16} className="text-accent-orange flex-shrink-0" /> <Table2 size={15} className="text-warning-500" />
<span className="font-medium text-white text-sm truncate">{tab.tableName}</span> <span className="font-medium text-text-primary text-sm">{tab.tableName}</span>
<span className="text-text-tertiary text-xs flex-shrink-0">({tab.total.toLocaleString()})</span> </div>
<span className="text-text-muted text-xs">{tab.total.toLocaleString()} </span>
{isLoading && ( {isLoading && (
<div className="flex items-center gap-1.5 text-accent-blue text-xs flex-shrink-0"> <div className="flex items-center gap-1.5 text-primary-500 text-xs">
<Loader2 size={12} className="animate-spin" /> <Loader2 size={13} className="animate-spin" />
... ...
</div> </div>
)} )}
</div> </div>
{/* 中间:修改提示 */}
{hasChanges && ( {hasChanges && (
<div className="flex items-center gap-2 flex-shrink-0"> <div className="px-2.5 py-1 bg-warning-50 text-warning-600 text-xs font-medium rounded-md border border-warning-200">
<span className="text-xs text-accent-orange font-medium px-1.5 py-0.5 bg-accent-orange/10 rounded"> {changesCount}
{changesCount}
{newRowCount > 0 && <span className="ml-1 text-accent-green">+{newRowCount}</span>}
</span>
</div> </div>
)} )}
{/* 右侧:分页控件 - 固定宽度 */} <div className="flex items-center gap-1.5">
<div className="flex items-center gap-1 flex-shrink-0">
<button <button
onClick={() => onLoadPage(tab.page - 1)} onClick={() => onLoadPage(tab.page - 1)}
disabled={tab.page <= 1 || isLoading} 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} /> <ChevronLeft size={16} />
</button> </button>
<span className="text-xs whitespace-nowrap min-w-[70px] text-center"> <span className="text-xs min-w-[70px] text-center">
<span className="text-accent-blue font-medium">{tab.page}</span>/{totalPages} <span className="text-primary-600 font-medium">{tab.page}</span> / {totalPages}
</span> </span>
<button <button
onClick={() => onLoadPage(tab.page + 1)} onClick={() => onLoadPage(tab.page + 1)}
disabled={tab.page >= totalPages || isLoading} 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} /> <ChevronRight size={16} />
</button> </button>
@ -397,25 +376,22 @@ const TableViewer = memo(function TableViewer({
value={tab.pageSize} value={tab.pageSize}
onChange={(e) => onChangePageSize?.(parseInt(e.target.value))} onChange={(e) => onChangePageSize?.(parseInt(e.target.value))}
disabled={isLoading} 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" className="h-7 px-2 text-xs bg-white border border-border-default rounded cursor-pointer"
title="每页条数"
> >
<option value={100}>100</option> <option value={100}>100</option>
<option value={500}>500</option> <option value={500}>500</option>
<option value={1000}>1000</option> <option value={1000}>1000</option>
<option value={2000}>2000</option> <option value={2000}>2000</option>
<option value={5000}>5000</option>
</select> </select>
</div> </div>
</div> </div>
{/* 数据表格 - 使用虚拟滚动 */} {/* 表格 */}
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}> <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{/* Loading 遮罩 */}
{isLoading && ( {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"> <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> <span className="text-sm text-text-secondary">...</span>
</div> </div>
</div> </div>
@ -429,60 +405,39 @@ const TableViewer = memo(function TableViewer({
primaryKeyColumn={primaryKeyCol} primaryKeyColumn={primaryKeyCol}
modifiedCells={modifiedCells} modifiedCells={modifiedCells}
onCellChange={(visibleRowIndex, colName, value) => { onCellChange={(visibleRowIndex, colName, value) => {
// 判断是修改现有行还是新增行
if (visibleRowIndex >= existingDataCount) { if (visibleRowIndex >= existingDataCount) {
// 这是新增的行 onUpdateNewRow?.(visibleRowIndex - existingDataCount, colName, value)
const newRowIndex = visibleRowIndex - existingDataCount
onUpdateNewRow?.(newRowIndex, colName, value)
} else { } else {
const originalIndex = originalIndexMap[visibleRowIndex] onCellChange?.(originalIndexMap[visibleRowIndex], colName, value)
onCellChange?.(originalIndex, colName, value)
} }
}} }}
onDeleteRow={(visibleRowIndex) => { onDeleteRow={(visibleRowIndex) => {
if (visibleRowIndex >= existingDataCount) { if (visibleRowIndex >= existingDataCount) {
// 删除新增的行 onDeleteNewRow?.(visibleRowIndex - existingDataCount)
const newRowIndex = visibleRowIndex - existingDataCount
onDeleteNewRow?.(newRowIndex)
} else { } else {
const originalIndex = originalIndexMap[visibleRowIndex] onDeleteRow?.(originalIndexMap[visibleRowIndex])
onDeleteRow?.(originalIndex)
} }
}} }}
onDeleteRows={(visibleRowIndices) => { onDeleteRows={(visibleRowIndices) => {
const originalIndices: number[] = [] const originalIndices: number[] = []
const newRowIndices: number[] = [] const newRowIndices: number[] = []
visibleRowIndices.forEach(i => { visibleRowIndices.forEach(i => {
if (i >= existingDataCount) { if (i >= existingDataCount) newRowIndices.push(i - existingDataCount)
newRowIndices.push(i - existingDataCount) else originalIndices.push(originalIndexMap[i])
} else {
originalIndices.push(originalIndexMap[i])
}
})
if (originalIndices.length > 0) {
onDeleteRows?.(originalIndices)
}
// 从后往前删除新增行,避免索引问题
newRowIndices.sort((a, b) => b - a).forEach(i => {
onDeleteNewRow?.(i)
}) })
if (originalIndices.length > 0) onDeleteRows?.(originalIndices)
newRowIndices.sort((a, b) => b - a).forEach(i => onDeleteNewRow?.(i))
}} }}
onRefresh={onRefresh} onRefresh={onRefresh}
onSave={onSave} onSave={onSave}
onAddRow={onAddRow} onAddRow={onAddRow}
onBatchUpdate={(updates) => { onBatchUpdate={(updates) => {
updates.forEach(({ rowIndex, colName, value }) => { updates.forEach(({ rowIndex, colName, value }) => {
// 判断是修改现有行还是新增行
if (rowIndex >= existingDataCount) { if (rowIndex >= existingDataCount) {
const newRowIndex = rowIndex - existingDataCount onUpdateNewRow?.(rowIndex - existingDataCount, colName, value)
onUpdateNewRow?.(newRowIndex, colName, value)
} else { } else {
const originalIndex = originalIndexMap[rowIndex] const originalIndex = originalIndexMap[rowIndex]
if (originalIndex !== undefined) { if (originalIndex !== undefined) onCellChange?.(originalIndex, colName, value)
onCellChange?.(originalIndex, colName, value)
}
} }
}) })
}} }}
@ -490,82 +445,35 @@ const TableViewer = memo(function TableViewer({
</div> </div>
</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"> <div className="flex items-center gap-0.5">
<button <button onClick={onAddRow} disabled={isLoading}
onClick={onAddRow} 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">
disabled={isLoading} <Plus size={15} />
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> </button>
<button <button onClick={() => newRowCount > 0 && onDeleteNewRow?.(newRowCount - 1)} disabled={isLoading || newRowCount === 0}
onClick={() => { 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">
// 如果有选中行删除选中的行此功能已在VirtualDataTable中的右键菜单中实现 <Minus size={15} />
// 这里作为快捷按钮,可以删除最后一个新增行
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> </button>
<div className="w-px h-4 bg-border-default mx-1" />
<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 rounded ${hasChanges ? 'hover:bg-success-50 text-success-500' : 'text-text-disabled'}`}>
<button <Check size={15} />
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} />
</button> </button>
<button <button onClick={onDiscard} disabled={isLoading || !hasChanges}
onClick={onDiscard} className={`w-7 h-7 flex items-center justify-center rounded ${hasChanges ? 'hover:bg-danger-50 text-danger-500' : 'text-text-disabled'}`}>
disabled={isLoading || !hasChanges} <X size={15} />
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> </button>
<button <button onClick={onRefresh} disabled={isLoading}
onClick={onRefresh} className="w-7 h-7 flex items-center justify-center hover:bg-light-hover disabled:opacity-40 rounded text-text-tertiary">
disabled={isLoading} <RefreshCw size={13} />
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> </button>
</div> </div>
<div className="flex-1 text-center text-xs text-text-muted">
{/* 中间:状态信息 */} {hasChanges ? `${tab.pendingChanges?.size || 0} 修改 · ${tab.deletedRows?.size || 0} 删除 · ${newRowCount} 新增` : `${visibleData.length}`}
<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> </div>
<div className="text-xs text-text-disabled font-mono">
{/* 右侧SQL 提示 */}
<div className="text-xs text-text-disabled">
SELECT * FROM `{tab.tableName}` LIMIT {tab.pageSize} SELECT * FROM `{tab.tableName}` LIMIT {tab.pageSize}
</div> </div>
</div> </div>
@ -573,15 +481,9 @@ const TableViewer = memo(function TableViewer({
) )
}) })
// 查询编辑器组件 // 查询编辑器
const QueryEditor = memo(function QueryEditor({ const QueryEditor = memo(function QueryEditor({
tab, tab, databases, tables, columns, onRun, onUpdateSql, onUpdateTitle
databases,
tables,
columns,
onRun,
onUpdateSql,
onUpdateTitle
}: { }: {
tab: QueryTab tab: QueryTab
databases: string[] databases: string[]
@ -620,12 +522,7 @@ const QueryEditor = memo(function QueryEditor({
const handleFormat = useCallback(() => { const handleFormat = useCallback(() => {
try { try {
const formatted = format(sql, { const formatted = format(sql, { language: 'mysql', tabWidth: 2, keywordCase: 'upper', linesBetweenQueries: 2 })
language: 'mysql',
tabWidth: 2,
keywordCase: 'upper',
linesBetweenQueries: 2,
})
setSql(formatted) setSql(formatted)
onUpdateSql(formatted) onUpdateSql(formatted)
} catch (err) { } catch (err) {
@ -643,203 +540,122 @@ const QueryEditor = memo(function QueryEditor({
const handleExportCsv = useCallback(async () => { const handleExportCsv = useCallback(async () => {
if (!tab.results || tab.results.rows.length === 0) return if (!tab.results || tab.results.rows.length === 0) return
const electronAPI = (window as any).electronAPI const electronAPI = (window as any).electronAPI
if (!electronAPI) return if (!electronAPI) return
const path = await electronAPI.saveDialog({ filters: [{ name: 'CSV', extensions: ['csv'] }], defaultPath: `query_${Date.now()}.csv` })
const path = await electronAPI.saveDialog({
filters: [{ name: 'CSV', extensions: ['csv'] }],
defaultPath: `query_results_${Date.now()}.csv`
})
if (!path) return if (!path) return
const header = tab.results.columns.join(',')
const columnNames = tab.results.columns const rows = tab.results.rows.map(row => row.map((v: any) => v === null ? '' : typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v)).join(',')).join('\n')
const 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')
await electronAPI.writeFile(path, `${header}\n${rows}`) await electronAPI.writeFile(path, `${header}\n${rows}`)
}, [tab.results]) }, [tab.results])
const handleExportSql = useCallback(async () => { const handleExportSql = useCallback(async () => {
if (!tab.results || tab.results.rows.length === 0) return if (!tab.results || tab.results.rows.length === 0) return
const electronAPI = (window as any).electronAPI const electronAPI = (window as any).electronAPI
if (!electronAPI) return if (!electronAPI) return
const path = await electronAPI.saveDialog({ filters: [{ name: 'SQL', extensions: ['sql'] }], defaultPath: `query_${Date.now()}.sql` })
const path = await electronAPI.saveDialog({
filters: [{ name: 'SQL', extensions: ['sql'] }],
defaultPath: `query_results_${Date.now()}.sql`
})
if (!path) return if (!path) return
let sqlContent = `-- ${new Date().toLocaleString()}\n-- ${tab.results.rows.length}\n\n`
const tableName = 'table_name' tab.results.rows.forEach(row => {
const columnNames = tab.results.columns const values = row.map((val: any) => val === null ? 'NULL' : typeof val === 'number' ? val : `'${String(val).replace(/'/g, "''")}'`).join(', ')
const rows = tab.results.rows sqlContent += `INSERT INTO table_name (\`${tab.results!.columns.join('`, `')}\`) VALUES (${values});\n`
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`
}) })
await electronAPI.writeFile(path, sqlContent) await electronAPI.writeFile(path, sqlContent)
}, [tab.results]) }, [tab.results])
// 结果数据转换为表格格式
const resultData = tab.results?.rows.map(row => { const resultData = tab.results?.rows.map(row => {
const obj: Record<string, any> = {} const obj: Record<string, any> = {}
tab.results?.columns.forEach((col, i) => { tab.results?.columns.forEach((col, i) => { obj[col] = row[i] })
obj[col] = row[i]
})
return obj return obj
}) || [] }) || []
const resultColumns = tab.results?.columns.map(col => { const resultColumns = tab.results?.columns.map(col => {
const colInfo = findColumnInfo(col) const colInfo = findColumnInfo(col)
return { return { name: col, type: colInfo?.type, key: colInfo?.key, comment: colInfo?.comment }
name: col,
type: colInfo?.type,
key: colInfo?.key,
comment: colInfo?.comment,
}
}) || [] }) || []
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* SQL 编辑区 */} {/* 工具栏 */}
<div style={{ height: '200px', flexShrink: 0, display: 'flex', flexDirection: 'column', borderBottom: '1px solid #5d5d5d' }}> <div style={{ height: '200px', flexShrink: 0, display: 'flex', flexDirection: 'column', borderBottom: '1px solid #e2e8f0' }}>
<div className="h-10 bg-metro-bg flex items-center px-2 gap-2" style={{ flexShrink: 0 }}> <div className="h-11 bg-light-surface flex items-center px-3 gap-2" style={{ flexShrink: 0 }}>
<button <button onClick={handleRun}
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">
className="h-7 px-4 bg-accent-green hover:bg-accent-green/90 flex items-center gap-1.5 text-sm transition-colors" <Play size={13} fill="currentColor" />
title="执行 SQL (Ctrl+Enter)"
>
<Play size={14} fill="currentColor" />
</button> </button>
<div className="w-px h-5 bg-border-default mx-1" />
<div className="w-px h-5 bg-white/20 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">
<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)"
>
<FolderOpen size={14} /> <FolderOpen size={14} />
</button> </button>
<button onClick={handleSaveFile}
<button 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">
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)"
>
<Save size={14} /> <Save size={14} />
</button> </button>
<button onClick={handleFormat}
<button 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">
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)"
>
<AlignLeft size={14} /> <AlignLeft size={14} />
</button> </button>
<div className="w-px h-5 bg-border-default mx-1" />
<div className="w-px h-5 bg-white/20 mx-1" />
{/* 导出按钮 */}
<div className="relative"> <div className="relative">
<button <button onClick={() => setShowExportMenu(!showExportMenu)} disabled={!tab.results || tab.results.rows.length === 0}
onClick={() => setShowExportMenu(!showExportMenu)} className="h-8 px-3 bg-white hover:bg-light-hover border border-border-default flex items-center gap-1.5 text-sm rounded-lg transition-colors disabled:opacity-40">
className="h-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}
>
<Download size={14} /> <Download size={14} />
</button> </button>
{showExportMenu && ( {showExportMenu && (
<> <>
<div className="fixed inset-0" onClick={() => setShowExportMenu(false)} /> <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"> <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 <button onClick={() => { handleExportCsv(); setShowExportMenu(false) }}
onClick={() => { handleExportCsv(); setShowExportMenu(false) }} className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-2">
className="w-full px-3 py-2 text-left text-sm hover:bg-accent-blue/20 flex items-center gap-2" <FileSpreadsheet size={14} className="text-success-500" /> CSV
>
<FileSpreadsheet size={14} className="text-accent-green" />
CSV
</button> </button>
<button <button onClick={() => { handleExportSql(); setShowExportMenu(false) }}
onClick={() => { handleExportSql(); setShowExportMenu(false) }} className="w-full px-3 py-2 text-left text-sm hover:bg-light-hover flex items-center gap-2">
className="w-full px-3 py-2 text-left text-sm hover:bg-accent-blue/20 flex items-center gap-2" <FileCode size={14} className="text-warning-500" /> SQL
>
<FileCode size={14} className="text-accent-orange" />
SQL
</button> </button>
</div> </div>
</> </>
)} )}
</div> </div>
<span className="text-xs text-text-muted ml-auto">
<span className="text-xs text-white/40 ml-auto"> {filePath && <span className="mr-3 text-primary-500 font-mono">{filePath.split(/[/\\]/).pop()}</span>}
{filePath && <span className="mr-3 text-accent-blue">{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>
Ctrl+Enter | Ctrl+S
</span> </span>
</div> </div>
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>
<Suspense fallback={<EditorLoading />}> <Suspense fallback={<EditorLoading />}>
<SqlEditor <SqlEditor value={sql} onChange={setSql} onRun={handleRun} onSave={handleSaveFile} onOpen={handleOpenFile} onFormat={handleFormat}
value={sql} databases={databases} tables={tables} columns={columns} />
onChange={setSql}
onRun={handleRun}
onSave={handleSaveFile}
onOpen={handleOpenFile}
onFormat={handleFormat}
databases={databases}
tables={tables}
columns={columns}
/>
</Suspense> </Suspense>
</div> </div>
</div> </div>
{/* 结果 */} {/* 结果 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <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 }}> <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-white/60"> <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> </span>
</div> </div>
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}> <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}> <div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
{tab.results ? ( {tab.results ? (
<VirtualDataTable <VirtualDataTable columns={resultColumns} data={resultData} showColumnInfo={true} onRefresh={() => onRun(sql)} />
columns={resultColumns}
data={resultData}
showColumnInfo={true}
onRefresh={() => onRun(sql)}
/>
) : ( ) : (
<div className="h-full flex items-center justify-center text-white/30"> <div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-3">
<Database size={32} className="text-white/20" /> <div className="w-12 h-12 rounded-xl bg-light-elevated flex items-center justify-center">
<span></span> <Database size={24} className="text-text-disabled" />
</div>
<span className="text-text-muted"></span>
</div> </div>
</div> </div>
)} )}

View File

@ -2,7 +2,7 @@ import { Plus, Database, Table2, ChevronRight, ChevronDown, Loader2, HardDrive,
import { Connection, DB_INFO, TableInfo } from '../types' import { Connection, DB_INFO, TableInfo } from '../types'
import { useState, useEffect, useRef, useCallback, memo } from 'react' import { useState, useEffect, useRef, useCallback, memo } from 'react'
// Navicat风格的表分组列表 // 表分组列表
const TableGroupList = memo(function TableGroupList({ const TableGroupList = memo(function TableGroupList({
tables, tables,
db, db,
@ -38,7 +38,6 @@ const TableGroupList = memo(function TableGroupList({
}) })
} }
// 自动展开表文件夹
useEffect(() => { useEffect(() => {
if (regularTables.length > 0) { if (regularTables.length > 0) {
setExpandedDbs(prev => new Set(prev).add(tablesKey)) setExpandedDbs(prev => new Set(prev).add(tablesKey))
@ -46,39 +45,40 @@ const TableGroupList = memo(function TableGroupList({
}, [regularTables.length, tablesKey, setExpandedDbs]) }, [regularTables.length, tablesKey, setExpandedDbs])
if (tables.length === 0) { 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 ( return (
<div className="py-0.5"> <div className="py-1">
{/* 表文件夹 */} {/* 表文件夹 */}
{regularTables.length > 0 && ( {regularTables.length > 0 && (
<div> <div>
<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)} onClick={() => toggleGroup(tablesKey)}
> >
<span className="text-text-tertiary"> <span className="text-text-muted">
{isTablesExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {isTablesExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span> </span>
<span className="text-accent-orange"> <span className="text-warning-500">
{isTablesExpanded ? <FolderOpen size={12} /> : <Folder size={12} />} {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>
<span className="flex-1"></span>
<span className="text-text-disabled">{regularTables.length}</span>
</div> </div>
{isTablesExpanded && ( {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 => ( {regularTables.map(table => (
<div <div
key={table.name} 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" 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"
title={table.name}
onClick={() => onOpenTable(connectionId, db, table.name)} onClick={() => onOpenTable(connectionId, db, table.name)}
onContextMenu={(e) => onContextMenu(e, table.name)} onContextMenu={(e) => onContextMenu(e, table.name)}
> >
<Table2 size={12} className="text-accent-orange flex-shrink-0" /> <Table2 size={12} className="text-warning-500 flex-shrink-0" />
<span className="truncate">{table.name}</span> <span className="truncate font-mono text-[11px]">{table.name}</span>
</div> </div>
))} ))}
</div> </div>
@ -88,32 +88,33 @@ const TableGroupList = memo(function TableGroupList({
{/* 视图文件夹 */} {/* 视图文件夹 */}
{views.length > 0 && ( {views.length > 0 && (
<div> <div className="mt-1">
<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(viewsKey)} onClick={() => toggleGroup(viewsKey)}
> >
<span className="text-text-tertiary"> <span className="text-text-muted">
{isViewsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {isViewsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span> </span>
<span className="text-accent-purple"> <span className="text-info-500">
{isViewsExpanded ? <FolderOpen size={12} /> : <Folder size={12} />} {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>
<span className="flex-1"></span>
<span className="text-text-disabled">{views.length}</span>
</div> </div>
{isViewsExpanded && ( {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 => ( {views.map(view => (
<div <div
key={view.name} 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" 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"
title={`${view.name} (视图)`}
onClick={() => onOpenTable(connectionId, db, view.name)} onClick={() => onOpenTable(connectionId, db, view.name)}
onContextMenu={(e) => onContextMenu(e, view.name)} onContextMenu={(e) => onContextMenu(e, view.name)}
> >
<Eye size={12} className="text-accent-purple flex-shrink-0" /> <Eye size={12} className="text-info-500 flex-shrink-0" />
<span className="truncate flex-1">{view.name}</span> <span className="truncate font-mono text-[11px]">{view.name}</span>
</div> </div>
))} ))}
</div> </div>
@ -128,7 +129,7 @@ interface Props {
connections: Connection[] connections: Connection[]
activeConnection: string | null activeConnection: string | null
connectedIds: Set<string> connectedIds: Set<string>
databasesMap: Map<string, string[]> // connectionId -> databases[] databasesMap: Map<string, string[]>
tablesMap: Map<string, TableInfo[]> tablesMap: Map<string, TableInfo[]>
selectedDatabase: string | null selectedDatabase: string | null
loadingDbSet: Set<string> loadingDbSet: Set<string>
@ -145,10 +146,8 @@ interface Props {
onExportTable?: (database: string, table: string, format: 'excel' | 'sql' | 'csv') => void onExportTable?: (database: string, table: string, format: 'excel' | 'sql' | 'csv') => void
onExportConnections?: (format: 'json' | 'ncx') => void onExportConnections?: (format: 'json' | 'ncx') => void
onImportConnections?: () => void onImportConnections?: () => void
// 数据库管理
onCreateDatabase?: (connectionId: string) => void onCreateDatabase?: (connectionId: string) => void
onDropDatabase?: (connectionId: string, database: string) => void onDropDatabase?: (connectionId: string, database: string) => void
// 表管理
onCreateTable?: (connectionId: string, database: string) => void onCreateTable?: (connectionId: string, database: string) => void
onDropTable?: (connectionId: string, database: string, table: string) => void onDropTable?: (connectionId: string, database: string, table: string) => void
onTruncateTable?: (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 onDesignTable?: (connectionId: string, database: string, table: string) => void
} }
// 计算菜单位置,防止超出屏幕 function getMenuPosition(x: number, y: number, menuHeight: number = 200, menuWidth: number = 200) {
function getMenuPosition(x: number, y: number, menuHeight: number = 200, menuWidth: number = 180) {
const windowHeight = window.innerHeight const windowHeight = window.innerHeight
const windowWidth = window.innerWidth const windowWidth = window.innerWidth
let finalX = x let finalX = x
let finalY = y let finalY = y
// 如果菜单会超出底部,则向上显示
if (y + menuHeight > windowHeight - 10) { if (y + menuHeight > windowHeight - 10) {
finalY = Math.max(10, y - menuHeight) finalY = Math.max(10, y - menuHeight)
} }
// 如果菜单会超出右侧,则向左显示
if (x + menuWidth > windowWidth - 10) { if (x + menuWidth > windowWidth - 10) {
finalX = Math.max(10, x - menuWidth) 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 [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 [tableMenu, setTableMenu] = useState<{ x: number; y: number; db: string; table: string; connectionId: string } | null>(null)
const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set()) const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set())
// 多选模式
const [multiSelectMode, setMultiSelectMode] = useState(false) const [multiSelectMode, setMultiSelectMode] = useState(false)
const [selectedConnections, setSelectedConnections] = useState<Set<string>>(new Set()) const [selectedConnections, setSelectedConnections] = useState<Set<string>>(new Set())
// 搜索功能
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [showSearch, setShowSearch] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null) const sidebarRef = useRef<HTMLDivElement>(null)
const [isFocused, setIsFocused] = useState(false) const [isFocused, setIsFocused] = useState(false)
@ -232,19 +223,16 @@ export default function Sidebar({
} }
}, [selectedDatabase]) }, [selectedDatabase])
// Ctrl+F 快捷键 - 只在侧边栏有焦点时触发
const handleSidebarKeyDown = useCallback((e: KeyboardEvent) => { const handleSidebarKeyDown = useCallback((e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && isFocused) { if ((e.ctrlKey || e.metaKey) && e.key === 'f' && isFocused) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setShowSearch(true)
setTimeout(() => searchInputRef.current?.focus(), 50) setTimeout(() => searchInputRef.current?.focus(), 50)
} }
if (e.key === 'Escape' && showSearch) { if (e.key === 'Escape' && searchQuery) {
setShowSearch(false)
setSearchQuery('') setSearchQuery('')
} }
}, [isFocused, showSearch]) }, [isFocused, searchQuery])
useEffect(() => { useEffect(() => {
const sidebar = sidebarRef.current const sidebar = sidebarRef.current
@ -254,39 +242,30 @@ export default function Sidebar({
} }
}, [handleSidebarKeyDown]) }, [handleSidebarKeyDown])
// 过滤表 - 从 tablesMap 获取指定数据库的表
const getFilteredTables = (db: string) => { const getFilteredTables = (db: string) => {
const dbTables = tablesMap.get(db) || [] const dbTables = tablesMap.get(db) || []
if (!searchQuery) return dbTables if (!searchQuery) return dbTables
return dbTables.filter(t => return dbTables.filter(t => t.name.toLowerCase().includes(searchQuery.toLowerCase()))
t.name.toLowerCase().includes(searchQuery.toLowerCase())
)
} }
// 检查数据库是否有匹配的表
const dbHasMatchingTables = (db: string) => { const dbHasMatchingTables = (db: string) => {
if (!searchQuery) return false if (!searchQuery) return false
const dbTables = tablesMap.get(db) || [] const dbTables = tablesMap.get(db) || []
return dbTables.some(t => t.name.toLowerCase().includes(searchQuery.toLowerCase())) return dbTables.some(t => t.name.toLowerCase().includes(searchQuery.toLowerCase()))
} }
// 过滤数据库:数据库名匹配 或者 该数据库下有匹配的表
const getFilteredDatabases = (connDatabases: string[]) => { const getFilteredDatabases = (connDatabases: string[]) => {
return connDatabases.filter(db => { return connDatabases.filter(db => {
if (!searchQuery) return true if (!searchQuery) return true
const query = searchQuery.toLowerCase() const query = searchQuery.toLowerCase()
// 数据库名匹配
if (db.toLowerCase().includes(query)) return true if (db.toLowerCase().includes(query)) return true
// 检查该数据库是否有匹配的表
if (dbHasMatchingTables(db)) return true if (dbHasMatchingTables(db)) return true
return false return false
}) })
} }
// 搜索时自动展开有匹配表的数据库
useEffect(() => { useEffect(() => {
if (searchQuery) { if (searchQuery) {
// 遍历所有连接的数据库
databasesMap.forEach((dbs) => { databasesMap.forEach((dbs) => {
dbs.forEach(db => { dbs.forEach(db => {
if (dbHasMatchingTables(db)) { if (dbHasMatchingTables(db)) {
@ -301,7 +280,7 @@ export default function Sidebar({
<> <>
<div <div
ref={sidebarRef} 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} tabIndex={0}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={(e) => { onBlur={(e) => {
@ -312,99 +291,102 @@ export default function Sidebar({
onMouseEnter={() => setIsFocused(true)} onMouseEnter={() => setIsFocused(true)}
onMouseLeave={() => setIsFocused(false)} onMouseLeave={() => setIsFocused(false)}
> >
{/* 新建连接按钮 + 导入导出 */} {/* 头部 */}
<div className="p-3 flex-shrink-0 space-y-2"> <div className="p-3 flex-shrink-0 space-y-2">
{/* 新建连接按钮 */}
<button <button
onClick={onNewConnection} 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 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> <span></span>
</button> </button>
{/* 导入导出按钮 */} {/* 导入导出 */}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={onImportConnections} 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 flex items-center justify-center gap-1.5 text-xs text-text-secondary
transition-all duration-150" transition-colors rounded-lg"
title="导入连接 (支持 JSON 和 Navicat NCX 格式)"
> >
<Upload size={14} /> <Upload size={13} />
<span></span> <span></span>
</button> </button>
<div className="relative group flex-1"> <div className="relative group flex-1">
<button <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 flex items-center justify-center gap-1.5 text-xs text-text-secondary
transition-all duration-150" transition-colors rounded-lg"
title="导出连接"
> >
<Download size={14} /> <Download size={13} />
<span></span> <span></span>
</button> </button>
{/* 导出格式下拉菜单 */} <div className="absolute left-0 right-0 top-full mt-1 bg-white border border-border-default
<div className="absolute left-0 right-0 top-full mt-1 bg-metro-card border border-metro-border rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible
shadow-metro-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50 overflow-hidden">
transition-all z-50">
<button <button
onClick={() => onExportConnections?.('json')} 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" /> <FileCode size={12} className="text-primary-500" />
JSON JSON
</button> </button>
<button <button
onClick={() => onExportConnections?.('ncx')} 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" /> <FileText size={12} className="text-warning-500" />
Navicat (.ncx) Navicat
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* 搜索框 - 始终显示 */} {/* 搜索框 */}
<div className="px-3 pb-2 flex-shrink-0"> <div className="px-3 pb-2 flex-shrink-0">
<div className="relative"> <div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled" /> <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder={selectedDatabase ? "搜索表名... (Ctrl+F)" : "搜索数据库... (Ctrl+F)"} placeholder="搜索..."
className="w-full h-8 pl-9 pr-8 bg-metro-surface text-sm text-white placeholder-text-disabled className="w-full h-8 pl-9 pr-8 bg-white text-sm text-text-primary placeholder-text-muted
border border-transparent focus:border-accent-blue transition-all rounded-sm" border border-border-default focus:border-primary-500 transition-all rounded-lg"
/> />
{searchQuery && ( {searchQuery && (
<button <button
onClick={() => setSearchQuery('')} onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-disabled hover:text-white transition-colors" className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary p-0.5"
> >
<X size={14} /> <X size={14} />
</button> </button>
)} )}
</div>
</div> </div>
</div>
{/* 连接列表 */} {/* 连接列表 */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0 scrollbar-thin">
<div className="px-3 py-1.5 text-xs font-medium text-text-tertiary uppercase tracking-wider flex items-center justify-between"> <div className="px-3 py-2 flex items-center justify-between">
<span> ({connections.length})</span> <span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
· {connections.length}
</span>
{connections.length > 0 && ( {connections.length > 0 && (
<button <button
onClick={() => { onClick={() => {
setMultiSelectMode(!multiSelectMode) setMultiSelectMode(!multiSelectMode)
if (multiSelectMode) setSelectedConnections(new Set()) if (multiSelectMode) setSelectedConnections(new Set())
}} }}
className={`p-1 rounded-sm transition-colors ${multiSelectMode ? 'bg-accent-blue text-white' : 'hover:bg-metro-hover'}`} className={`p-1 rounded transition-colors ${
title={multiSelectMode ? '退出多选' : '批量管理'} multiSelectMode
? 'bg-primary-500 text-white'
: 'hover:bg-light-hover text-text-muted'
}`}
> >
{multiSelectMode ? <CheckSquare size={12} /> : <Square size={12} />} {multiSelectMode ? <CheckSquare size={12} /> : <Square size={12} />}
</button> </button>
@ -413,56 +395,49 @@ export default function Sidebar({
{/* 多选操作栏 */} {/* 多选操作栏 */}
{multiSelectMode && selectedConnections.size > 0 && ( {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> <span className="text-xs text-text-tertiary"> {selectedConnections.size} </span>
<button <button
onClick={() => { onClick={() => {
if (confirm(`确定删除选中的 ${selectedConnections.size} 个连接`)) { if (confirm(`确定删除 ${selectedConnections.size} 个连接`)) {
onDeleteConnections?.([...selectedConnections]) onDeleteConnections?.([...selectedConnections])
setSelectedConnections(new Set()) setSelectedConnections(new Set())
setMultiSelectMode(false) 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>
<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> </div>
)} )}
{connections.length === 0 ? ( {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>
) : ( ) : (
<div className="px-2 space-y-0.5"> <div className="px-2 pb-3 space-y-0.5">
{connections.map(conn => { {connections.map(conn => {
const info = DB_INFO[conn.type] const info = DB_INFO[conn.type]
const isConnected = connectedIds.has(conn.id) const isConnected = connectedIds.has(conn.id)
const isActive = activeConnection === conn.id const isActive = activeConnection === conn.id
const isSelected = selectedConnections.has(conn.id) const isSelected = selectedConnections.has(conn.id)
const isExpanded = expandedDbs.has(conn.id) const isExpanded = expandedDbs.has(conn.id)
// 获取该连接的数据库列表
const connDatabases = databasesMap.get(conn.id) || [] const connDatabases = databasesMap.get(conn.id) || []
// 已展开且有数据库就显示
const showDatabases = isExpanded && isConnected && connDatabases.length > 0 const showDatabases = isExpanded && isConnected && connDatabases.length > 0
return ( return (
<div key={conn.id}> <div key={conn.id}>
{/* 连接项 */}
<div <div
className={`group flex items-center gap-2 px-2 py-2 cursor-pointer transition-all duration-150 rounded-sm className={`group flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-all rounded-lg
${isSelected ? 'bg-metro-hover ring-1 ring-text-tertiary' : ''} ${isSelected ? 'bg-primary-50 ring-1 ring-primary-200' : ''}
${isActive && !isSelected ${isActive && !isSelected ? 'bg-light-hover' : 'hover:bg-light-hover'}`}
? 'bg-metro-hover'
: 'hover:bg-metro-hover'} text-text-secondary hover:text-white`}
onClick={() => { onClick={() => {
if (multiSelectMode) { if (multiSelectMode) {
setSelectedConnections(prev => { setSelectedConnections(prev => {
@ -473,7 +448,6 @@ export default function Sidebar({
}) })
} else { } else {
onSelectConnection(conn.id) onSelectConnection(conn.id)
// 切换展开状态
if (isConnected) { if (isConnected) {
setExpandedDbs(prev => { setExpandedDbs(prev => {
const next = new Set(prev) const next = new Set(prev)
@ -487,43 +461,35 @@ export default function Sidebar({
onDoubleClick={async () => { onDoubleClick={async () => {
if (!multiSelectMode && !isConnected) { if (!multiSelectMode && !isConnected) {
onConnect(conn) onConnect(conn)
// 连接后自动展开
setExpandedDbs(prev => new Set(prev).add(conn.id)) setExpandedDbs(prev => new Set(prev).add(conn.id))
} }
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault() 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 }) setMenu({ x: pos.x, y: pos.y, conn })
}} }}
> >
{/* 复选框/箭头 - 同一列,根据模式显示不同内容 */}
<span className="w-4 flex-shrink-0 flex items-center justify-center"> <span className="w-4 flex-shrink-0 flex items-center justify-center">
{multiSelectMode ? ( {multiSelectMode ? (
<span className={`w-4 h-4 rounded-sm border flex items-center justify-center <span className={`w-4 h-4 rounded border-2 flex items-center justify-center text-[10px]
${isSelected ? 'bg-accent-blue border-accent-blue' : 'border-text-tertiary'}`}> ${isSelected ? 'bg-primary-500 border-primary-500 text-white' : 'border-border-strong'}`}>
{isSelected && <span className="text-white text-xs"></span>} {isSelected && '✓'}
</span> </span>
) : ( ) : (
<span className={`${isConnected ? 'text-text-tertiary' : 'opacity-0'}`}> <span className={isConnected ? 'text-text-muted' : 'opacity-0'}>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />} {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span> </span>
)} )}
</span> </span>
<span className="text-lg flex-shrink-0">{info?.icon}</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="flex-1 text-sm truncate font-medium text-text-primary">{conn.name}</span>
{/* 连接状态灯 - 右对齐 */} <span className={`status-dot flex-shrink-0 ${isConnected ? 'connected' : 'disconnected'}`} />
<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 ? '已连接' : '未连接'}
/>
</div> </div>
{/* 数据库列表 - 嵌套在连接下 */} {/* 数据库列表 */}
{showDatabases && isExpanded && ( {showDatabases && (
<div className="ml-4 border-l border-metro-border/50 mt-0.5"> <div className="ml-5 mt-0.5 pl-3 border-l border-border-light animate-slide-down">
{getFilteredDatabases(connDatabases).map(db => { {getFilteredDatabases(connDatabases).map(db => {
const isDbSelected = selectedDatabase === db const isDbSelected = selectedDatabase === db
const isDbExpanded = expandedDbs.has(db) const isDbExpanded = expandedDbs.has(db)
@ -533,16 +499,11 @@ export default function Sidebar({
return ( return (
<div key={db}> <div key={db}>
<div <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 className={`flex items-center gap-2 px-2.5 py-1.5 cursor-pointer text-sm transition-all rounded-lg mx-1
${isDbSelected ${isDbSelected ? 'bg-primary-50 text-primary-700' : 'text-text-secondary hover:bg-light-hover'}`}
? 'bg-metro-hover text-white font-medium'
: 'text-text-secondary hover:bg-metro-hover hover:text-white'}`}
onClick={() => { onClick={() => {
const willExpand = !expandedDbs.has(db) const willExpand = !expandedDbs.has(db)
// 展开时自动选择数据库以加载表 if (willExpand) onSelectDatabase(db, conn.id)
if (willExpand) {
onSelectDatabase(db, conn.id)
}
setExpandedDbs(prev => { setExpandedDbs(prev => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(db)) next.delete(db) if (next.has(db)) next.delete(db)
@ -552,23 +513,22 @@ export default function Sidebar({
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault() 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 }) 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} />} {isDbExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span> </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> <span className="flex-1 truncate">{db}</span>
</div> </div>
{/* 表列表 - Navicat风格分组 */}
{isDbExpanded && ( {isDbExpanded && (
<div className="ml-4 mt-0.5"> <div className="ml-4 mt-0.5">
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-text-tertiary"> <div className="flex items-center gap-2 px-3 py-2 text-xs text-text-muted">
<Loader2 size={12} className="animate-spin" /> <Loader2 size={12} className="animate-spin text-primary-500" />
... ...
</div> </div>
) : ( ) : (
@ -581,7 +541,7 @@ export default function Sidebar({
onOpenTable={onOpenTable} onOpenTable={onOpenTable}
onContextMenu={(e, tableName) => { onContextMenu={(e, tableName) => {
e.preventDefault() 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 }) setTableMenu({ x: pos.x, y: pos.y, db, table: tableName, connectionId: conn.id })
}} }}
/> />
@ -601,52 +561,54 @@ export default function Sidebar({
</div> </div>
</div> </div>
{/* Metro 风格右键菜单 - 连接 */} {/* 右键菜单 - 连接 */}
{menu && ( {menu && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setMenu(null)} /> <div className="fixed inset-0 z-40" onClick={() => setMenu(null)} />
<div <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 }} style={{ left: menu.x, top: menu.y }}
> >
{connectedIds.has(menu.conn.id) ? ( {connectedIds.has(menu.conn.id) ? (
<> <>
<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={() => { onDisconnect(menu.conn.id); setMenu(null) }} 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>
<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) }} onClick={() => { onCreateDatabase?.(menu.conn.id); setMenu(null) }}
> >
<PlusCircle size={14} className="text-accent-green" /> <PlusCircle size={14} className="text-success-500" />
</button> </button>
<div className="my-1 border-t border-metro-border" /> <div className="my-1.5 mx-2 border-t border-border-light" />
</> </>
) : ( ) : (
<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={() => { onConnect(menu.conn); setMenu(null) }} 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>
)} )}
<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) }} onClick={() => { onEditConnection(menu.conn); setMenu(null) }}
> >
<Edit3 size={14} className="text-text-muted" />
</button> </button>
<div className="my-1 border-t border-metro-border" /> <div className="my-1.5 mx-2 border-t border-border-light" />
<button <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) }} onClick={() => { onDeleteConnection(menu.conn.id); setMenu(null) }}
> >
<Trash2 size={14} />
</button> </button>
</div> </div>
@ -658,36 +620,36 @@ export default function Sidebar({
<> <>
<div className="fixed inset-0 z-40" onClick={() => setDbMenu(null)} /> <div className="fixed inset-0 z-40" onClick={() => setDbMenu(null)} />
<div <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 }} style={{ left: dbMenu.x, top: dbMenu.y }}
> >
<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={() => { onCreateTable?.(dbMenu.connectionId, dbMenu.db); setDbMenu(null) }} onClick={() => { onCreateTable?.(dbMenu.connectionId, dbMenu.db); setDbMenu(null) }}
> >
<PlusCircle size={14} className="text-accent-green" /> <PlusCircle size={14} className="text-success-500" />
</button> </button>
<div className="my-1 border-t border-metro-border" /> <div className="my-1.5 mx-2 border-t border-border-light" />
<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={() => { onRefreshTables?.(dbMenu.connectionId, dbMenu.db); setDbMenu(null) }} onClick={() => { onRefreshTables?.(dbMenu.connectionId, dbMenu.db); setDbMenu(null) }}
> >
<RefreshCw size={14} className="text-text-secondary" /> <RefreshCw size={14} className="text-text-muted" />
</button> </button>
<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) }} onClick={() => { onBackupDatabase?.(dbMenu.db); setDbMenu(null) }}
> >
<HardDrive size={14} className="text-accent-blue" /> <HardDrive size={14} className="text-primary-500" />
</button> </button>
<div className="my-1 border-t border-metro-border" /> <div className="my-1.5 mx-2 border-t border-border-light" />
<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={() => { onClick={() => {
if (confirm(`确定删除数据库 "${dbMenu.db}"此操作不可恢复!`)) { if (confirm(`确定删除数据库 "${dbMenu.db}"`)) {
onDropDatabase?.(dbMenu.connectionId, dbMenu.db) onDropDatabase?.(dbMenu.connectionId, dbMenu.db)
} }
setDbMenu(null) setDbMenu(null)
@ -705,68 +667,68 @@ export default function Sidebar({
<> <>
<div className="fixed inset-0 z-40" onClick={() => setTableMenu(null)} /> <div className="fixed inset-0 z-40" onClick={() => setTableMenu(null)} />
<div <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 }} 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} {tableMenu.table}
</div> </div>
<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={() => { onOpenTable(tableMenu.connectionId, tableMenu.db, tableMenu.table); setTableMenu(null) }} 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>
<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) }} 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>
<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) }} 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>
<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) }} 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> </button>
<div className="my-1 border-t border-metro-border" /> <div className="my-1.5 mx-2 border-t border-border-light" />
<div className="px-4 py-1.5 text-xs text-text-disabled"></div> <div className="px-3 py-1 text-[10px] text-text-muted uppercase"></div>
<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, 'excel'); setTableMenu(null) }} onClick={() => { onExportTable?.(tableMenu.db, tableMenu.table, 'excel'); setTableMenu(null) }}
> >
<FileSpreadsheet size={14} className="text-accent-green" /> <FileSpreadsheet size={14} className="text-success-500" />
Excel Excel
</button> </button>
<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) }} onClick={() => { onExportTable?.(tableMenu.db, tableMenu.table, 'sql'); setTableMenu(null) }}
> >
<FileCode size={14} className="text-accent-orange" /> <FileCode size={14} className="text-warning-500" />
SQL SQL
</button> </button>
<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) }} onClick={() => { onExportTable?.(tableMenu.db, tableMenu.table, 'csv'); setTableMenu(null) }}
> >
<FileText size={14} className="text-accent-blue" /> <FileText size={14} className="text-primary-500" />
CSV CSV
</button> </button>
<div className="my-1 border-t border-metro-border" /> <div className="my-1.5 mx-2 border-t border-border-light" />
<button <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={() => { onClick={() => {
if (confirm(`确定清空表 "${tableMenu.table}" 的所有数据吗此操作不可恢复!`)) { if (confirm(`确定清空表 "${tableMenu.table}"`)) {
onTruncateTable?.(tableMenu.connectionId, tableMenu.db, tableMenu.table) onTruncateTable?.(tableMenu.connectionId, tableMenu.db, tableMenu.table)
} }
setTableMenu(null) setTableMenu(null)
@ -776,9 +738,9 @@ export default function Sidebar({
</button> </button>
<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={() => { onClick={() => {
if (confirm(`确定删除表 "${tableMenu.table}"此操作不可恢复!`)) { if (confirm(`确定删除表 "${tableMenu.table}"`)) {
onDropTable?.(tableMenu.connectionId, tableMenu.db, tableMenu.table) onDropTable?.(tableMenu.connectionId, tableMenu.db, tableMenu.table)
} }
setTableMenu(null) setTableMenu(null)

View File

@ -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 { memo, useState } from 'react'
import api from '../lib/electron-api' import api from '../lib/electron-api'
@ -11,46 +11,44 @@ const TitleBar = memo(function TitleBar() {
} }
return ( 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="h-10 bg-white flex items-center justify-between drag select-none border-b border-border-default">
{/* 微妙的顶部高光效果 */} {/* Logo 区域 */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/5 to-transparent" />
{/* Logo */}
<div className="flex items-center h-full px-4 no-drag gap-2.5"> <div className="flex items-center h-full px-4 no-drag gap-2.5">
<div className="relative"> <div className="w-7 h-7 rounded-lg bg-primary-500 flex items-center justify-center">
<Database size={16} className="text-accent-blue" /> <Database size={15} className="text-white" />
<div className="absolute inset-0 bg-accent-blue/20 blur-md -z-10" />
</div> </div>
<span className="text-sm font-semibold tracking-wide text-white/90">EasySQL</span> <span className="text-sm font-semibold text-text-primary">EasySQL</span>
<span className="text-[10px] text-white/30 font-medium ml-1">v2.0</span> <span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary-50 text-primary-600">
v2.0
</span>
</div> </div>
{/* Window Controls - Windows 11 风格 */} {/* 窗口控制按钮 */}
<div className="flex h-full no-drag"> <div className="flex h-full no-drag">
<button <button
onClick={() => api.minimize()} 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="最小化" title="最小化"
> >
<Minus size={16} className="text-white/60 group-hover:text-white/90" /> <Minus size={15} className="text-text-tertiary" />
</button> </button>
<button <button
onClick={handleMaximize} 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 ? "还原" : "最大化"} title={isMaximized ? "还原" : "最大化"}
> >
{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>
<button <button
onClick={() => api.close()} 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="关闭" title="关闭"
> >
<X size={16} className="text-white/60 group-hover:text-white" /> <X size={15} className="text-text-tertiary group-hover:text-white" />
</button> </button>
</div> </div>
</div> </div>

View File

@ -721,7 +721,7 @@ const VirtualDataTable = memo(function VirtualDataTable({
minWidth: colWidth, minWidth: colWidth,
position: isPinned ? 'sticky' : 'relative', position: isPinned ? 'sticky' : 'relative',
left: isPinned ? pinnedLeftOffsets[col.name] : 'auto', 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, height: headerHeight,
}} }}
title={isPinned ? `取消固定 ${col.name}` : `固定 ${col.name}`} title={isPinned ? `取消固定 ${col.name}` : `固定 ${col.name}`}
@ -832,14 +832,14 @@ const VirtualDataTable = memo(function VirtualDataTable({
: formatDateTime(value, col.type || '') : formatDateTime(value, col.type || '')
} }
// 计算背景色 // 计算背景色 - 浅色主题
let bgColor = 'transparent' let bgColor = 'transparent'
if (isCurrentMatch) bgColor = '#665500' if (isCurrentMatch) bgColor = '#fef08a'
else if (isSearchMatch) bgColor = 'rgba(255, 200, 0, 0.15)' else if (isSearchMatch) bgColor = 'rgba(250, 204, 21, 0.2)'
else if (isActiveCell) bgColor = '#264f78' else if (isActiveCell) bgColor = 'rgba(59, 130, 246, 0.15)'
else if (isCellSelected) bgColor = 'rgba(38, 79, 120, 0.5)' else if (isCellSelected) bgColor = 'rgba(59, 130, 246, 0.1)'
else if (isModified) bgColor = 'rgba(249, 115, 22, 0.15)' else if (isModified) bgColor = 'rgba(249, 115, 22, 0.1)'
else if (isPinned) bgColor = '#1e2d3d' else if (isPinned) bgColor = '#f8fafc'
return ( return (
<div <div
@ -853,8 +853,8 @@ const VirtualDataTable = memo(function VirtualDataTable({
minWidth: colWidth, minWidth: colWidth,
maxWidth: colWidth, maxWidth: colWidth,
height: rowHeight, height: rowHeight,
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',
outline: isActiveCell && !isEditing ? '1px solid #007acc' : 'none', outline: isActiveCell && !isEditing ? '2px solid #3b82f6' : 'none',
outlineOffset: '-1px', outlineOffset: '-1px',
zIndex: isPinned ? 10 : 1, zIndex: isPinned ? 10 : 1,
}} }}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { Connection, QueryTab, TableInfo, ColumnInfo, TableTab } from '../types'
import api from './electron-api'
// 防抖 Hook // 防抖 Hook
export function useDebounce<T>(value: T, delay: number): T { 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 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
}
}

View File

@ -60,15 +60,24 @@ export interface TableTab {
newRows?: any[] // 新增的行数据(尚未保存到数据库) newRows?: any[] // 新增的行数据(尚未保存到数据库)
} }
export const DB_INFO: Record<DatabaseType, { name: string; icon: string; color: string; port: number; supported: boolean }> = { export const DB_INFO: Record<DatabaseType, {
mysql: { name: 'MySQL', icon: '🐬', color: '#00758f', port: 3306, supported: true }, name: string
postgres: { name: 'PostgreSQL', icon: '🐘', color: '#336791', port: 5432, supported: true }, icon: string
sqlite: { name: 'SQLite', icon: '💾', color: '#003b57', port: 0, supported: true }, color: string
mongodb: { name: 'MongoDB', icon: '🍃', color: '#47a248', port: 27017, supported: true }, defaultPort: number
redis: { name: 'Redis', icon: '⚡', color: '#dc382d', port: 6379, supported: true }, supported: boolean
sqlserver: { name: 'SQL Server', icon: '📊', color: '#cc2927', port: 1433, supported: true }, needsHost: boolean
oracle: { name: 'Oracle', icon: '🔶', color: '#f80000', port: 1521, supported: false }, needsAuth: boolean
mariadb: { name: 'MariaDB', icon: '🦭', color: '#c0765a', port: 3306, supported: true }, needsFile: boolean
snowflake: { name: 'Snowflake', icon: '❄️', color: '#29b5e8', port: 443, supported: false }, }> = {
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 },
} }

View File

@ -7,53 +7,119 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// Windows Metro 深色主题配色 // 简约浅色科技主题 - Clean Light
metro: { light: {
dark: '#1a1a1a', // 最深背景 // 背景层次 - 从白到浅灰
bg: '#252525', // 主背景 bg: '#ffffff', // 主背景白色
surface: '#2d2d2d', // 表面 surface: '#f8fafc', // 表面浅灰
card: '#323232', // 卡片 elevated: '#f1f5f9', // 浮起层
hover: '#3a3a3a', // 悬停 muted: '#e2e8f0', // 静音背景
border: '#404040', // 边框 hover: '#f1f5f9', // 悬停
divider: '#333333', // 分割线 active: '#e2e8f0', // 激活
}, },
// Metro 强调色 - Windows 11 风格 // 边框颜色
accent: { border: {
blue: '#0078d4', // 主强调色 light: '#f1f5f9',
'blue-hover': '#1a86d9', default: '#e2e8f0',
'blue-light': '#60cdff', strong: '#cbd5e1',
green: '#0f7b0f', // 成功
'green-hover': '#1c9a1c',
red: '#c42b1c', // 错误/删除
'red-hover': '#d13d2d',
orange: '#f7630c', // 警告
purple: '#886ce4', // 紫色
teal: '#00b294', // 青色
yellow: '#ffd800', // 黄色
}, },
// 文字颜色 // 主色调 - 现代蓝
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: { text: {
primary: '#ffffff', primary: '#0f172a', // 深色主文字
secondary: 'rgba(255, 255, 255, 0.7)', secondary: '#334155', // 次要文字 (加深)
tertiary: 'rgba(255, 255, 255, 0.5)', tertiary: '#475569', // 第三级文字 (加深)
disabled: 'rgba(255, 255, 255, 0.3)', muted: '#64748b', // 静音文字 (加深)
} disabled: '#94a3b8', // 禁用文字
inverse: '#ffffff', // 反色文字
},
}, },
fontFamily: { fontFamily: {
sans: ['Segoe UI', 'system-ui', 'sans-serif'], sans: ['Inter', 'SF Pro Display', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
mono: ['Cascadia Code', 'Consolas', 'monospace'], mono: ['JetBrains Mono', 'SF Mono', 'Consolas', 'monospace'],
}, },
boxShadow: { boxShadow: {
'metro': '0 2px 4px rgba(0, 0, 0, 0.2)', // 现代阴影系统
'metro-lg': '0 4px 12px rgba(0, 0, 0, 0.3)', 'xs': '0 1px 2px rgba(0, 0, 0, 0.03)',
'metro-xl': '0 8px 24px rgba(0, 0, 0, 0.4)', '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: { animation: {
'fade-in': 'fadeIn 0.2s ease', 'fade-in': 'fadeIn 0.2s ease-out',
'slide-up': 'slideUp 0.25s cubic-bezier(0.4, 0, 0.2, 1)', 'slide-up': 'slideUp 0.25s ease-out',
'slide-down': 'slideDown 0.25s cubic-bezier(0.4, 0, 0.2, 1)', 'slide-down': 'slideDown 0.25s ease-out',
'scale-in': 'scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1)', 'scale-in': 'scaleIn 0.2s ease-out',
'pulse-soft': 'pulseSoft 2s ease-in-out infinite', 'spin-slow': 'spin 1.5s linear infinite',
}, },
keyframes: { keyframes: {
fadeIn: { fadeIn: {
@ -61,24 +127,17 @@ export default {
'100%': { opacity: '1' }, '100%': { opacity: '1' },
}, },
slideUp: { slideUp: {
'0%': { opacity: '0', transform: 'translateY(8px)' }, '0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' }, '100%': { opacity: '1', transform: 'translateY(0)' },
}, },
slideDown: { slideDown: {
'0%': { opacity: '0', transform: 'translateY(-8px)' }, '0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' }, '100%': { opacity: '1', transform: 'translateY(0)' },
}, },
scaleIn: { scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' }, '0%': { opacity: '0', transform: 'scale(0.96)' },
'100%': { opacity: '1', transform: 'scale(1)' }, '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)',
}, },
}, },
}, },