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, Loader2, Shield, FolderOpen } from 'lucide-react'
import { Connection, DatabaseType, DB_INFO } from '../types'
import { X, Database, Check, AlertCircle, ChevronDown, ChevronRight, Shield, Globe, Server, Key, User, Folder, FileText } from 'lucide-react'
import { Connection, DB_INFO, DatabaseType } from '../types'
import { useState, useEffect, useRef } from 'react'
import api from '../lib/electron-api'
interface Props {
connection: Connection | null
defaultType?: DatabaseType
onSave: (conn: Connection) => void
isOpen: boolean
editingConnection?: Connection | null
initialType?: DatabaseType
onClose: () => void
onSave: (conn: Omit<Connection, 'id'> & { id?: string }) => void
}
export default function ConnectionModal({ connection, defaultType, onSave, onClose }: Props) {
const initialType = defaultType || 'mysql'
const initialPort = DB_INFO[initialType]?.port || 3306
const [form, setForm] = useState<Connection>({
id: '',
name: '',
type: initialType,
host: 'localhost',
port: initialPort,
username: '',
password: '',
database: '',
sshEnabled: false,
sshHost: '',
sshPort: 22,
sshUser: '',
sshPassword: '',
sshKey: '',
})
const [testing, setTesting] = useState(false)
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null)
export default function ConnectionModal({ isOpen, editingConnection, initialType, onClose, onSave }: Props) {
const [selectedType, setSelectedType] = useState<DatabaseType>(editingConnection?.type || initialType || 'mysql')
const [name, setName] = useState(editingConnection?.name || '')
const [host, setHost] = useState(editingConnection?.host || 'localhost')
const [port, setPort] = useState(editingConnection?.port || DB_INFO[selectedType].defaultPort)
const [username, setUsername] = useState(editingConnection?.username || '')
const [password, setPassword] = useState(editingConnection?.password || '')
const [database, setDatabase] = useState(editingConnection?.database || '')
const [file, setFile] = useState(editingConnection?.file || '')
const [useSSH, setUseSSH] = useState(editingConnection?.ssh?.enabled || false)
const [sshHost, setSshHost] = useState(editingConnection?.ssh?.host || '')
const [sshPort, setSshPort] = useState(editingConnection?.ssh?.port || 22)
const [sshUser, setSshUser] = useState(editingConnection?.ssh?.username || '')
const [sshPassword, setSshPassword] = useState(editingConnection?.ssh?.password || '')
const [sshKeyFile, setSshKeyFile] = useState(editingConnection?.ssh?.privateKeyPath || '')
const [showAdvanced, setShowAdvanced] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const nameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (connection) {
setForm(connection)
} else {
const type = defaultType || 'mysql'
const port = DB_INFO[type]?.port || 3306
setForm(prev => ({
...prev,
id: `conn-${Date.now()}`,
type,
port,
name: DB_INFO[type]?.name || ''
}))
if (isOpen) {
const timer = setTimeout(() => nameInputRef.current?.focus(), 100)
return () => clearTimeout(timer)
}
}, [connection, defaultType])
}, [isOpen])
useEffect(() => {
if (editingConnection) {
setSelectedType(editingConnection.type)
setName(editingConnection.name)
setHost(editingConnection.host || 'localhost')
setPort(editingConnection.port || DB_INFO[editingConnection.type].defaultPort)
setUsername(editingConnection.username || '')
setPassword(editingConnection.password || '')
setDatabase(editingConnection.database || '')
setFile(editingConnection.file || '')
setUseSSH(editingConnection.ssh?.enabled || false)
setSshHost(editingConnection.ssh?.host || '')
setSshPort(editingConnection.ssh?.port || 22)
setSshUser(editingConnection.ssh?.username || '')
setSshPassword(editingConnection.ssh?.password || '')
setSshKeyFile(editingConnection.ssh?.privateKeyPath || '')
} else {
const type = initialType || 'mysql'
setSelectedType(type)
setName('')
setHost('localhost')
setPort(DB_INFO[type].defaultPort)
setUsername('')
setPassword('')
setDatabase('')
setFile('')
setUseSSH(false)
setSshHost('')
setSshPort(22)
setSshUser('')
setSshPassword('')
setSshKeyFile('')
}
setMessage(null)
}, [editingConnection, isOpen, initialType])
const handleTypeChange = (type: DatabaseType) => {
const info = DB_INFO[type]
setForm(prev => ({ ...prev, type, port: info?.port || prev.port }))
setSelectedType(type)
setPort(DB_INFO[type].defaultPort)
setMessage(null)
}
const handleTest = async () => {
setTesting(true)
setMessage(null)
const result = await api.testConnection(form)
setMessage({
text: result?.message || '测试失败',
type: result?.success ? 'success' : 'error'
})
setTesting(false)
try {
const connData = buildConnection()
const result = await api.testConnection(connData)
if (result.success) {
setMessage({ type: 'success', text: '连接成功!' })
} else {
setMessage({ type: 'error', text: result.error || '连接失败' })
}
} catch (err) {
setMessage({ type: 'error', text: '测试失败:' + (err as Error).message })
}
setTimeout(() => setMessage(null), 3000)
}
const buildConnection = (): Omit<Connection, 'id'> & { id?: string } => {
const info = DB_INFO[selectedType]
return {
...(editingConnection?.id ? { id: editingConnection.id } : {}),
type: selectedType,
name: name || `${info.name} 连接`,
host: info.needsHost ? host : undefined,
port: info.needsHost ? port : undefined,
username: info.needsAuth ? username : undefined,
password: info.needsAuth ? password : undefined,
database: database || undefined,
file: info.needsFile ? file : undefined,
ssh: useSSH && info.needsHost ? { enabled: true, host: sshHost, port: sshPort, username: sshUser, password: sshPassword || undefined, privateKeyPath: sshKeyFile || undefined } : undefined,
}
}
const handleSave = () => {
if (!form.name.trim()) {
setMessage({ text: '请输入连接名称', type: 'error' })
if (!name.trim()) {
setMessage({ type: 'error', text: '请输入连接名称' })
setTimeout(() => setMessage(null), 3000)
return
}
onSave(form)
onSave(buildConnection())
onClose()
}
const handleSelectFile = async () => {
const filePath = await api.selectFile([{ name: 'SQLite', extensions: ['db', 'sqlite', 'sqlite3'] }])
if (filePath) setFile(filePath)
}
const handleSelectKeyFile = async () => {
const filePath = await api.selectFile([{ name: 'PEM', extensions: ['pem', 'key', 'ppk'] }])
if (filePath) setSshKeyFile(filePath)
}
if (!isOpen) return null
const info = DB_INFO[selectedType]
const isEditing = !!editingConnection
return (
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Metro 风格弹窗 */}
<div className="relative w-[560px] max-h-[90vh] bg-metro-bg flex flex-col overflow-hidden shadow-metro-xl animate-slide-up">
{/* 标题栏 */}
<div className="h-14 bg-accent-blue flex items-center justify-between px-5">
<span className="font-semibold text-lg">{connection ? '编辑连接' : '新建连接'}</span>
<button onClick={onClose} className="p-1.5 hover:bg-white/20 transition-colors rounded-sm">
<X size={20} />
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 animate-fade-in backdrop-blur-sm" onClick={onClose}>
<div className="w-[520px] max-h-[90vh] bg-white flex flex-col overflow-hidden rounded-2xl shadow-modal animate-scale-in" onClick={e => e.stopPropagation()}>
{/* 标题 */}
<div className="h-14 flex items-center justify-between px-5 border-b border-border-default">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ backgroundColor: info.color + '15' }}>
<span className="text-xl">{info.icon}</span>
</div>
<div>
<h2 className="text-base font-semibold text-text-primary">
{isEditing ? '编辑连接' : '新建连接'}
</h2>
<p className="text-xs text-text-muted">{info.name}</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-light-hover rounded-lg transition-colors">
<X size={18} className="text-text-tertiary" />
</button>
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{/* 连接名称 */}
<div>
<label className="block text-sm text-text-secondary mb-2 font-medium"></label>
<input
type="text"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
placeholder="输入名称"
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
{/* 数据库类型 - Metro 磁贴选择 */}
<div>
<label className="block text-sm text-text-secondary mb-3 font-medium"></label>
<div className="grid grid-cols-3 gap-2">
{(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][]).map(([key, info]) => (
<button
key={key}
onClick={() => info.supported && handleTypeChange(key)}
className={`h-16 flex items-center gap-3 px-4 transition-all metro-tile relative
${!info.supported ? 'cursor-not-allowed' : ''}
${form.type === key && info.supported
? 'ring-2 ring-white ring-inset shadow-metro-lg'
: info.supported ? 'opacity-60 hover:opacity-100' : ''}`}
style={{
backgroundColor: info.color,
opacity: info.supported ? (form.type === key ? 1 : 0.6) : 0.3,
filter: info.supported ? 'none' : 'grayscale(60%)'
}}
disabled={!info.supported}
title={info.supported ? info.name : `${info.name} - 即将支持`}
>
<span className="text-2xl">{info.icon}</span>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">{info.name}</span>
{!info.supported && (
<span className="text-[10px] text-white/60"></span>
)}
</div>
</button>
))}
</div>
</div>
{/* SQLite 文件选择 */}
{form.type === 'sqlite' ? (
<div className="flex-1 overflow-y-auto scrollbar-thin">
<div className="p-5 space-y-5">
{/* 数据库类型选择 */}
<div>
<label className="block text-sm text-text-secondary mb-2 font-medium"></label>
<div className="flex gap-2">
<input
type="text"
value={form.database}
onChange={(e) => setForm(prev => ({ ...prev, database: e.target.value }))}
placeholder="选择或输入 .db 文件路径"
className="flex-1 h-10 px-4 bg-metro-surface border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
<button
onClick={async () => {
const result = await api.selectFile(['db', 'sqlite', 'sqlite3'])
if (result?.path) {
setForm(prev => ({ ...prev, database: result.path }))
}
}}
className="h-10 px-4 bg-metro-surface hover:bg-metro-hover flex items-center gap-2 text-sm transition-colors rounded-sm"
>
<FolderOpen size={16} />
</button>
<label className="block text-xs font-medium text-text-secondary mb-2"></label>
<div className="flex flex-wrap gap-2">
{(Object.entries(DB_INFO) as [DatabaseType, typeof DB_INFO[DatabaseType]][])
.filter(([, i]) => i.supported)
.map(([type, i]) => (
<button
key={type}
onClick={() => handleTypeChange(type)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all
${selectedType === type
? 'border-primary-500 bg-primary-50 text-primary-700 shadow-focus'
: 'border-border-default hover:border-border-strong text-text-primary hover:bg-light-hover'}`}
>
<span className="text-lg">{i.icon}</span>
<span className="font-medium">{i.name}</span>
</button>
))}
</div>
<p className="text-xs text-text-disabled mt-2"></p>
</div>
) : (
<>
{/* 主机和端口 */}
<div className="grid grid-cols-4 gap-4">
<div className="col-span-3">
<label className="block text-sm text-text-secondary mb-2 font-medium"></label>
{/* 连接名称 */}
<div>
<label className="block text-xs font-medium text-text-secondary mb-2">
<User size={12} className="inline mr-1" />
</label>
<input
ref={nameInputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={`我的${info.name}连接`}
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
/>
</div>
{/* SQLite 文件路径 */}
{info.needsFile && (
<div>
<label className="block text-xs font-medium text-text-secondary mb-2">
<FileText size={12} className="inline mr-1" />
</label>
<div className="flex gap-2">
<input
type="text"
value={form.host}
onChange={(e) => setForm(prev => ({ ...prev, host: e.target.value }))}
placeholder="localhost"
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
value={file}
onChange={(e) => setFile(e.target.value)}
placeholder="选择或输入 .db 文件路径"
className="flex-1 h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-2 font-medium"></label>
<input
type="number"
value={form.port}
onChange={(e) => setForm(prev => ({ ...prev, port: parseInt(e.target.value) || 0 }))}
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
</div>
{/* 用户名密码 - Redis 只需要密码 */}
{form.type === 'redis' ? (
<div>
<label className="block text-sm text-text-secondary mb-2 font-medium">
<span className="text-text-disabled font-normal">()</span>
</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))}
placeholder="无密码时留空"
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-text-secondary mb-2 font-medium">
{form.type === 'mongodb' && <span className="text-text-disabled font-normal">()</span>}
</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm(prev => ({ ...prev, username: e.target.value }))}
placeholder={form.type === 'mongodb' ? '无认证时留空' : 'root'}
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-2 font-medium">
{form.type === 'mongodb' && <span className="text-text-disabled font-normal">()</span>}
</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))}
placeholder={form.type === 'mongodb' ? '无认证时留空' : ''}
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
</div>
)}
{/* 数据库 */}
<div>
<label className="block text-sm text-text-secondary mb-2 font-medium">
<span className="text-text-disabled font-normal">()</span>
</label>
<input
type="text"
value={form.database}
onChange={(e) => setForm(prev => ({ ...prev, database: e.target.value }))}
placeholder={form.type === 'mongodb' ? '默认 admin' : '留空表示连接所有数据库'}
className="w-full h-10 px-4 bg-metro-surface border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
</>
)}
{/* SSH */}
<div className="pt-4 border-t border-metro-border">
<label className="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
checked={form.sshEnabled}
onChange={(e) => setForm(prev => ({ ...prev, sshEnabled: e.target.checked }))}
className="w-5 h-5 accent-accent-blue cursor-pointer"
/>
<Shield size={18} className={form.sshEnabled ? 'text-accent-green' : 'text-text-disabled'} />
<span className="text-sm font-medium group-hover:text-white transition-colors">SSH </span>
</label>
{form.sshEnabled && (
<div className="mt-4 p-4 bg-metro-surface rounded-sm space-y-4 border-l-2 border-accent-green">
<div className="grid grid-cols-4 gap-3">
<div className="col-span-3">
<label className="block text-xs text-text-tertiary mb-1.5">SSH </label>
<input
type="text"
value={form.sshHost}
onChange={(e) => setForm(prev => ({ ...prev, sshHost: e.target.value }))}
className="w-full h-9 px-3 bg-metro-bg border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
<div>
<label className="block text-xs text-text-tertiary mb-1.5"></label>
<input
type="number"
value={form.sshPort}
onChange={(e) => setForm(prev => ({ ...prev, sshPort: parseInt(e.target.value) || 22 }))}
className="w-full h-9 px-3 bg-metro-bg border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-text-tertiary mb-1.5">SSH </label>
<input
type="text"
value={form.sshUser}
onChange={(e) => setForm(prev => ({ ...prev, sshUser: e.target.value }))}
className="w-full h-9 px-3 bg-metro-bg border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
<div>
<label className="block text-xs text-text-tertiary mb-1.5">SSH </label>
<input
type="password"
value={form.sshPassword}
onChange={(e) => setForm(prev => ({ ...prev, sshPassword: e.target.value }))}
className="w-full h-9 px-3 bg-metro-bg border-2 border-transparent
focus:border-accent-blue text-sm transition-all rounded-sm"
/>
</div>
<button
onClick={handleSelectFile}
className="h-10 px-4 bg-white hover:bg-light-hover border border-border-default rounded-lg text-sm text-text-primary transition-colors flex items-center gap-1.5"
>
<Folder size={14} />
</button>
</div>
</div>
)}
</div>
{/* 消息 */}
{message && (
<div className={`p-4 text-sm rounded-sm ${message.type === 'success' ? 'bg-accent-green/20 text-accent-green border-l-2 border-accent-green' : 'bg-accent-red/20 text-accent-red border-l-2 border-accent-red'}`}>
{message.text}
</div>
)}
{/* 主机和端口 */}
{info.needsHost && (
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="block text-xs font-medium text-text-secondary mb-2">
<Globe size={12} className="inline mr-1" />
</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="localhost"
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-secondary mb-2">
<Server size={12} className="inline mr-1" />
</label>
<input
type="number"
value={port}
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
/>
</div>
</div>
)}
{/* 认证信息 */}
{info.needsAuth && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-secondary mb-2">
<User size={12} className="inline mr-1" />
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="root"
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-secondary mb-2">
<Key size={12} className="inline mr-1" />
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
/>
</div>
</div>
)}
{/* 数据库名称 */}
{info.needsHost && (
<div>
<label className="block text-xs font-medium text-text-secondary mb-2">
<Database size={12} className="inline mr-1" />
<span className="text-text-muted font-normal ml-1">()</span>
</label>
<input
type="text"
value={database}
onChange={(e) => setDatabase(e.target.value)}
placeholder="连接后自动选择的数据库"
className="w-full h-10 px-3 bg-light-surface border border-border-default rounded-lg focus:border-primary-500 focus:shadow-focus transition-all"
/>
</div>
)}
{/* SSH 设置 */}
{info.needsHost && (
<div className="border border-border-default rounded-xl overflow-hidden">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-light-hover transition-colors"
>
<div className="flex items-center gap-2">
<Shield size={14} className="text-teal-500" />
<span className="text-sm font-medium text-text-primary">SSH </span>
</div>
{showAdvanced ? <ChevronDown size={16} className="text-text-tertiary" /> : <ChevronRight size={16} className="text-text-tertiary" />}
</button>
{showAdvanced && (
<div className="px-4 pb-4 pt-2 border-t border-border-light bg-light-surface space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={useSSH}
onChange={(e) => setUseSSH(e.target.checked)}
className="w-4 h-4 rounded border-border-strong text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-text-secondary"> SSH </span>
</label>
{useSSH && (
<div className="space-y-3 mt-3">
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<label className="block text-xs text-text-muted mb-1">SSH </label>
<input
type="text"
value={sshHost}
onChange={(e) => setSshHost(e.target.value)}
className="w-full h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1"></label>
<input
type="number"
value={sshPort}
onChange={(e) => setSshPort(parseInt(e.target.value) || 22)}
className="w-full h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-text-muted mb-1"></label>
<input
type="text"
value={sshUser}
onChange={(e) => setSshUser(e.target.value)}
className="w-full h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1"></label>
<input
type="password"
value={sshPassword}
onChange={(e) => setSshPassword(e.target.value)}
className="w-full h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
/>
</div>
</div>
<div>
<label className="block text-xs text-text-muted mb-1"> <span className="text-text-disabled">()</span></label>
<div className="flex gap-2">
<input
type="text"
value={sshKeyFile}
onChange={(e) => setSshKeyFile(e.target.value)}
placeholder="~/.ssh/id_rsa"
className="flex-1 h-9 px-3 bg-white border border-border-default rounded-lg text-sm focus:border-primary-500 focus:shadow-focus"
/>
<button onClick={handleSelectKeyFile}
className="h-9 px-3 bg-white hover:bg-light-hover border border-border-default rounded-lg text-sm">
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* 消息提示 */}
{message && (
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg animate-slide-up
${message.type === 'success' ? 'bg-success-50 text-success-600 border border-success-200' : 'bg-danger-50 text-danger-600 border border-danger-200'}`}>
{message.type === 'success' ? <Check size={16} /> : <AlertCircle size={16} />}
<span className="text-sm">{message.text}</span>
</div>
)}
</div>
</div>
{/* 底部按钮 */}
<div className="h-16 bg-metro-surface flex items-center justify-end gap-3 px-5 border-t border-metro-border/50">
<button
onClick={handleTest}
disabled={testing}
className="h-10 px-5 bg-transparent border border-text-tertiary hover:border-white hover:bg-white/5
text-sm transition-all disabled:opacity-50 flex items-center gap-2 rounded-sm"
>
{testing && <Loader2 size={14} className="animate-spin" />}
<div className="h-16 flex items-center justify-end gap-3 px-5 border-t border-border-default bg-light-surface">
<button onClick={handleTest}
className="h-9 px-4 bg-white hover:bg-light-hover border border-border-default rounded-lg text-sm font-medium text-text-primary transition-colors">
</button>
<button
onClick={handleSave}
className="h-10 px-8 bg-accent-blue hover:bg-accent-blue-hover text-sm font-medium transition-all shadow-metro rounded-sm"
>
</button>
<button
onClick={onClose}
className="h-10 px-5 bg-metro-hover hover:bg-metro-border text-sm transition-all rounded-sm"
>
<button onClick={onClose}
className="h-9 px-4 bg-white hover:bg-light-hover border border-border-default rounded-lg text-sm font-medium text-text-primary transition-colors">
</button>
<button onClick={handleSave}
className="h-9 px-5 bg-primary-500 hover:bg-primary-600 text-white rounded-lg text-sm font-medium shadow-btn hover:shadow-btn-hover transition-all">
{isEditing ? '保存' : '创建'}
</button>
</div>
</div>
</div>

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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 { Connection, QueryTab, TableInfo, ColumnInfo, TableTab } from '../types'
import api from './electron-api'
// 防抖 Hook
export function useDebounce<T>(value: T, delay: number): T {
@ -215,3 +217,197 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
return [storedValue, setValue] as const
}
// ============================================
// 业务 Hooks
// ============================================
// 连接管理 Hook
export function useConnections() {
const [connections, setConnections] = useState<Connection[]>([])
const [connectedIds, setConnectedIds] = useState<Set<string>>(new Set())
// 加载保存的连接
useEffect(() => {
const loadConnections = async () => {
try {
const saved = await api.getConnections()
if (saved && Array.isArray(saved)) {
setConnections(saved)
}
} catch (err) {
console.error('加载连接失败:', err)
}
}
loadConnections()
}, [])
// 添加连接
const addConnection = useCallback((conn: Omit<Connection, 'id'>) => {
const newConn: Connection = { ...conn, id: `conn-${Date.now()}` } as Connection
setConnections(prev => {
const updated = [...prev, newConn]
api.saveConnections(updated)
return updated
})
}, [])
// 删除连接
const deleteConnection = useCallback((id: string) => {
setConnections(prev => {
const updated = prev.filter(c => c.id !== id)
api.saveConnections(updated)
return updated
})
}, [])
// 更新连接
const updateConnection = useCallback((conn: Connection) => {
setConnections(prev => {
const updated = prev.map(c => c.id === conn.id ? conn : c)
api.saveConnections(updated)
return updated
})
}, [])
return {
connections,
setConnections,
connectedIds,
setConnectedIds,
addConnection,
deleteConnection,
updateConnection
}
}
// 数据库操作 Hook
export function useDatabaseOperations(showNotification: (type: 'success' | 'error' | 'info', msg: string) => void) {
const [databasesMap, setDatabasesMap] = useState<Map<string, string[]>>(new Map())
const [loadingDbSet, setLoadingDbSet] = useState<Set<string>>(new Set())
const fetchDatabases = useCallback(async (connectionId: string) => {
try {
const dbs = await api.getDatabases(connectionId)
setDatabasesMap(prev => new Map(prev).set(connectionId, dbs))
} catch (err) {
showNotification('error', '获取数据库列表失败')
}
}, [showNotification])
return {
databasesMap,
setDatabasesMap,
loadingDbSet,
setLoadingDbSet,
fetchDatabases
}
}
// 表操作 Hook
export function useTableOperations(showNotification: (type: 'success' | 'error' | 'info', msg: string) => void) {
const [tablesMap, setTablesMap] = useState<Map<string, TableInfo[]>>(new Map())
const [columnsMap, setColumnsMap] = useState<Map<string, ColumnInfo[]>>(new Map())
const fetchTables = useCallback(async (connectionId: string, database: string) => {
try {
const tables = await api.getTables(connectionId, database)
setTablesMap(prev => new Map(prev).set(database, tables))
} catch (err) {
showNotification('error', '获取表列表失败')
}
}, [showNotification])
const fetchColumns = useCallback(async (connectionId: string, database: string, table: string) => {
try {
const cols = await api.getTableColumns(connectionId, database, table)
setColumnsMap(prev => new Map(prev).set(table, cols))
} catch (err) {
// 忽略列获取失败
}
}, [])
return {
tablesMap,
setTablesMap,
columnsMap,
setColumnsMap,
fetchTables,
fetchColumns
}
}
// Tab 操作 Hook
export function useTabOperations() {
const [tabs, setTabs] = useState<(QueryTab | TableTab)[]>([])
const [activeTab, setActiveTab] = useState<string>('welcome')
const [loadingTables, setLoadingTables] = useState<Set<string>>(new Set())
return {
tabs,
setTabs,
activeTab,
setActiveTab,
loadingTables,
setLoadingTables
}
}
// 查询操作 Hook
export function useQueryOperations(
tabs: (QueryTab | TableTab)[],
setTabs: React.Dispatch<React.SetStateAction<(QueryTab | TableTab)[]>>,
showNotification: (type: 'success' | 'error' | 'info', msg: string) => void
) {
const runQuery = useCallback(async (tabId: string, connectionId: string, sql: string) => {
try {
const result = await api.executeQuery(connectionId, sql)
setTabs(prev => prev.map(t => {
if (t.id === tabId && !('tableName' in t)) {
return { ...t, results: result }
}
return t
}))
} catch (err) {
showNotification('error', '查询失败:' + (err as Error).message)
}
}, [setTabs, showNotification])
return { runQuery }
}
// 导入导出 Hook
export function useImportExport(
connections: Connection[],
setConnections: React.Dispatch<React.SetStateAction<Connection[]>>,
showNotification: (type: 'success' | 'error' | 'info', msg: string) => void
) {
const importConnections = useCallback(async () => {
try {
const result = await api.importConnections()
if (result && result.length > 0) {
setConnections(prev => {
const updated = [...prev, ...result]
api.saveConnections(updated)
return updated
})
showNotification('success', `已导入 ${result.length} 个连接`)
}
} catch (err) {
showNotification('error', '导入失败:' + (err as Error).message)
}
}, [setConnections, showNotification])
const exportConnections = useCallback(async (format: 'json' | 'ncx') => {
try {
await api.exportConnections(connections, format)
showNotification('success', '导出成功')
} catch (err) {
showNotification('error', '导出失败:' + (err as Error).message)
}
}, [connections, showNotification])
return {
importConnections,
exportConnections
}
}

View File

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

View File

@ -7,53 +7,119 @@ export default {
theme: {
extend: {
colors: {
// Windows Metro 深色主题配色
metro: {
dark: '#1a1a1a', // 最深背景
bg: '#252525', // 主背景
surface: '#2d2d2d', // 表面
card: '#323232', // 卡片
hover: '#3a3a3a', // 悬停
border: '#404040', // 边框
divider: '#333333', // 分割线
// 简约浅色科技主题 - Clean Light
light: {
// 背景层次 - 从白到浅灰
bg: '#ffffff', // 主背景白色
surface: '#f8fafc', // 表面浅灰
elevated: '#f1f5f9', // 浮起层
muted: '#e2e8f0', // 静音背景
hover: '#f1f5f9', // 悬停
active: '#e2e8f0', // 激活
},
// Metro 强调色 - Windows 11 风格
accent: {
blue: '#0078d4', // 主强调色
'blue-hover': '#1a86d9',
'blue-light': '#60cdff',
green: '#0f7b0f', // 成功
'green-hover': '#1c9a1c',
red: '#c42b1c', // 错误/删除
'red-hover': '#d13d2d',
orange: '#f7630c', // 警告
purple: '#886ce4', // 紫色
teal: '#00b294', // 青色
yellow: '#ffd800', // 黄色
// 边框颜色
border: {
light: '#f1f5f9',
default: '#e2e8f0',
strong: '#cbd5e1',
},
// 文字颜色
// 主色调 - 现代蓝
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6', // 主色
600: '#2563eb',
700: '#1d4ed8',
},
// 成功绿
success: {
50: '#f0fdf4',
100: '#dcfce7',
500: '#22c55e',
600: '#16a34a',
},
// 警告橙
warning: {
50: '#fffbeb',
100: '#fef3c7',
500: '#f59e0b',
600: '#d97706',
},
// 错误红
danger: {
50: '#fef2f2',
100: '#fee2e2',
500: '#ef4444',
600: '#dc2626',
},
// 信息紫
info: {
50: '#faf5ff',
100: '#f3e8ff',
500: '#a855f7',
600: '#9333ea',
},
// 青色 - 数据库/表
teal: {
50: '#f0fdfa',
100: '#ccfbf1',
500: '#14b8a6',
600: '#0d9488',
},
// 数据库品牌色
db: {
mysql: '#00758f',
postgresql: '#336791',
sqlite: '#003b57',
sqlserver: '#cc2927',
mongodb: '#47a248',
redis: '#dc382d',
mariadb: '#003545',
},
// 文字颜色 - 加深以提高可读性
text: {
primary: '#ffffff',
secondary: 'rgba(255, 255, 255, 0.7)',
tertiary: 'rgba(255, 255, 255, 0.5)',
disabled: 'rgba(255, 255, 255, 0.3)',
}
primary: '#0f172a', // 深色主文字
secondary: '#334155', // 次要文字 (加深)
tertiary: '#475569', // 第三级文字 (加深)
muted: '#64748b', // 静音文字 (加深)
disabled: '#94a3b8', // 禁用文字
inverse: '#ffffff', // 反色文字
},
},
fontFamily: {
sans: ['Segoe UI', 'system-ui', 'sans-serif'],
mono: ['Cascadia Code', 'Consolas', 'monospace'],
sans: ['Inter', 'SF Pro Display', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
mono: ['JetBrains Mono', 'SF Mono', 'Consolas', 'monospace'],
},
boxShadow: {
'metro': '0 2px 4px rgba(0, 0, 0, 0.2)',
'metro-lg': '0 4px 12px rgba(0, 0, 0, 0.3)',
'metro-xl': '0 8px 24px rgba(0, 0, 0, 0.4)',
// 现代阴影系统
'xs': '0 1px 2px rgba(0, 0, 0, 0.03)',
'sm': '0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.03)',
'md': '0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03)',
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.03)',
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.02)',
// 卡片阴影
'card': '0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02)',
'card-hover': '0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)',
// 按钮阴影
'btn': '0 1px 2px rgba(59, 130, 246, 0.1), 0 1px 3px rgba(59, 130, 246, 0.08)',
'btn-hover': '0 4px 12px rgba(59, 130, 246, 0.2), 0 2px 4px rgba(59, 130, 246, 0.1)',
// 弹窗阴影
'modal': '0 25px 50px -12px rgba(0, 0, 0, 0.12)',
// 输入框聚焦
'focus': '0 0 0 3px rgba(59, 130, 246, 0.15)',
},
borderRadius: {
'4xl': '2rem',
},
animation: {
'fade-in': 'fadeIn 0.2s ease',
'slide-up': 'slideUp 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'slide-down': 'slideDown 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'scale-in': 'scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
'fade-in': 'fadeIn 0.2s ease-out',
'slide-up': 'slideUp 0.25s ease-out',
'slide-down': 'slideDown 0.25s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
'spin-slow': 'spin 1.5s linear infinite',
},
keyframes: {
fadeIn: {
@ -61,24 +127,17 @@ export default {
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideDown: {
'0%': { opacity: '0', transform: 'translateY(-8px)' },
'0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'0%': { opacity: '0', transform: 'scale(0.96)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
pulseSoft: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.7' },
},
},
transitionTimingFunction: {
'metro': 'cubic-bezier(0.4, 0, 0.2, 1)',
},
},
},