核心功能: - SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮 - SFTP管理: 双栏文件管理、上传下载、传输队列 - 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理 架构改进: - 拆分大型组件 (SFTPView/TabBar/Sidebar) - 添加统一日志和错误处理 - 修复React严格模式下的终端黑屏问题 UI更新: - 新增端口转发视图 - 更新UI预览文档 - 左侧栏右键菜单支持SFTP快捷打开
388 lines
15 KiB
TypeScript
388 lines
15 KiB
TypeScript
/**
|
|
* PortForwardView - 端口转发管理视图
|
|
*
|
|
* 支持本地端口转发、远程端口转发和动态 SOCKS5 代理
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useTabStore } from '../stores/tabStore'
|
|
import { useConnectionPool } from '../stores/connection'
|
|
import { ArrowRightLeft, Globe, Server, Plus, Trash2, Play, Square } from 'lucide-react'
|
|
|
|
interface PortForwardViewProps {
|
|
tabId: string
|
|
isActive?: boolean
|
|
}
|
|
|
|
interface ForwardRule {
|
|
id: string
|
|
type: 'local' | 'remote' | 'dynamic'
|
|
name: string
|
|
localPort: number
|
|
remoteHost?: string
|
|
remotePort?: number
|
|
isActive: boolean
|
|
}
|
|
|
|
export default function PortForwardView({ tabId, isActive: _isActive = true }: PortForwardViewProps) {
|
|
const { tabs } = useTabStore()
|
|
const pool = useConnectionPool()
|
|
|
|
const currentTab = tabs.find(t => t.id === tabId)
|
|
const connectionId = currentTab?.connectionInstance?.connectionId
|
|
const connection = connectionId ? pool.getConnection(connectionId) : null
|
|
|
|
const [rules, setRules] = useState<ForwardRule[]>([])
|
|
const [showAddModal, setShowAddModal] = useState(false)
|
|
const [newRule, setNewRule] = useState<Partial<ForwardRule>>({
|
|
type: 'local',
|
|
name: '',
|
|
localPort: 0,
|
|
remoteHost: '',
|
|
remotePort: 0,
|
|
})
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Load saved rules from settings
|
|
useEffect(() => {
|
|
if (connectionId) {
|
|
window.electronAPI?.settingsGet(`portForwards_${connectionId}`).then((result) => {
|
|
if (result.success && result.value) {
|
|
setRules(JSON.parse(result.value as string))
|
|
}
|
|
})
|
|
}
|
|
}, [connectionId])
|
|
|
|
// Save rules
|
|
const saveRules = (newRules: ForwardRule[]) => {
|
|
setRules(newRules)
|
|
if (connectionId) {
|
|
window.electronAPI?.settingsSet(`portForwards_${connectionId}`, JSON.stringify(newRules))
|
|
}
|
|
}
|
|
|
|
// Add new rule
|
|
const handleAddRule = async () => {
|
|
if (!newRule.name || !newRule.localPort) {
|
|
setError('请填写完整信息')
|
|
return
|
|
}
|
|
|
|
if (newRule.type !== 'dynamic' && (!newRule.remoteHost || !newRule.remotePort)) {
|
|
setError('请填写远程主机和端口')
|
|
return
|
|
}
|
|
|
|
const rule: ForwardRule = {
|
|
id: `pf-${Date.now()}`,
|
|
type: newRule.type as 'local' | 'remote' | 'dynamic',
|
|
name: newRule.name,
|
|
localPort: newRule.localPort,
|
|
remoteHost: newRule.remoteHost,
|
|
remotePort: newRule.remotePort,
|
|
isActive: false,
|
|
}
|
|
|
|
saveRules([...rules, rule])
|
|
setShowAddModal(false)
|
|
setNewRule({ type: 'local', name: '', localPort: 0, remoteHost: '', remotePort: 0 })
|
|
setError(null)
|
|
}
|
|
|
|
// Start port forward
|
|
const startForward = async (rule: ForwardRule) => {
|
|
if (!connectionId) {
|
|
setError('未连接到服务器')
|
|
return
|
|
}
|
|
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
let result
|
|
if (rule.type === 'local') {
|
|
result = await window.electronAPI?.sshForwardLocal(
|
|
connectionId,
|
|
rule.localPort,
|
|
rule.remoteHost || '',
|
|
rule.remotePort || 0
|
|
)
|
|
} else if (rule.type === 'remote') {
|
|
result = await window.electronAPI?.sshForwardRemote(
|
|
connectionId,
|
|
rule.remotePort || 0,
|
|
'127.0.0.1',
|
|
rule.localPort
|
|
)
|
|
} else if (rule.type === 'dynamic') {
|
|
result = await window.electronAPI?.sshForwardDynamic(connectionId, rule.localPort)
|
|
}
|
|
|
|
if (result?.success) {
|
|
const updatedRules = rules.map(r =>
|
|
r.id === rule.id ? { ...r, isActive: true } : r
|
|
)
|
|
saveRules(updatedRules)
|
|
} else {
|
|
setError(result?.error || '启动失败')
|
|
}
|
|
} catch (err: any) {
|
|
setError(err.message || '启动失败')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
// Stop port forward
|
|
const stopForward = async (rule: ForwardRule) => {
|
|
// Note: Actual stop implementation would require storing server references
|
|
const updatedRules = rules.map(r =>
|
|
r.id === rule.id ? { ...r, isActive: false } : r
|
|
)
|
|
saveRules(updatedRules)
|
|
}
|
|
|
|
// Delete rule
|
|
const deleteRule = (ruleId: string) => {
|
|
if (confirm('确定删除此转发规则?')) {
|
|
saveRules(rules.filter(r => r.id !== ruleId))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-dark-900">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
|
<div className="flex items-center gap-3">
|
|
<ArrowRightLeft className="w-6 h-6 text-accent-blue" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-white">端口转发</h2>
|
|
<p className="text-sm text-gray-500">
|
|
{connection
|
|
? `已连接: ${connection.config.username}@${connection.config.host}`
|
|
: '未连接 - 请先建立 SSH 连接'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowAddModal(true)}
|
|
disabled={!connection}
|
|
className="flex items-center gap-2 px-4 py-2 bg-accent-blue hover:bg-blue-600 disabled:bg-dark-700 disabled:text-gray-500 rounded-lg text-sm font-medium transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
添加规则
|
|
</button>
|
|
</div>
|
|
|
|
{/* Rules List */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{rules.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
|
<Globe className="w-16 h-16 mb-4 opacity-50" />
|
|
<p className="text-lg">暂无端口转发规则</p>
|
|
<p className="text-sm mt-1">点击右上角添加规则</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{rules.map((rule) => (
|
|
<div
|
|
key={rule.id}
|
|
className={`p-4 rounded-lg border transition-all ${
|
|
rule.isActive
|
|
? 'bg-green-500/10 border-green-500/30'
|
|
: 'bg-dark-800 border-dark-700'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
{/* Type Icon */}
|
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
|
rule.type === 'local' ? 'bg-blue-500/20 text-blue-400' :
|
|
rule.type === 'remote' ? 'bg-purple-500/20 text-purple-400' :
|
|
'bg-orange-500/20 text-orange-400'
|
|
}`}>
|
|
{rule.type === 'local' && <ArrowRightLeft className="w-5 h-5" />}
|
|
{rule.type === 'remote' && <Server className="w-5 h-5" />}
|
|
{rule.type === 'dynamic' && <Globe className="w-5 h-5" />}
|
|
</div>
|
|
|
|
{/* Rule Info */}
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-white">{rule.name}</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
rule.type === 'local' ? 'bg-blue-500/20 text-blue-400' :
|
|
rule.type === 'remote' ? 'bg-purple-500/20 text-purple-400' :
|
|
'bg-orange-500/20 text-orange-400'
|
|
}`}>
|
|
{rule.type === 'local' ? '本地转发' :
|
|
rule.type === 'remote' ? '远程转发' : '动态 SOCKS5'}
|
|
</span>
|
|
{rule.isActive && (
|
|
<span className="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-400">
|
|
运行中
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-gray-400 mt-1">
|
|
{rule.type === 'dynamic' ? (
|
|
<span>SOCKS5 代理: 127.0.0.1:{rule.localPort}</span>
|
|
) : rule.type === 'local' ? (
|
|
<span>127.0.0.1:{rule.localPort} → {rule.remoteHost}:{rule.remotePort}</span>
|
|
) : (
|
|
<span>远程 {rule.remotePort} → 本地 {rule.localPort}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
{rule.isActive ? (
|
|
<button
|
|
onClick={() => stopForward(rule)}
|
|
className="p-2 text-yellow-400 hover:bg-yellow-400/10 rounded transition-colors"
|
|
title="停止"
|
|
>
|
|
<Square className="w-5 h-5" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => startForward(rule)}
|
|
disabled={isLoading || !connection}
|
|
className="p-2 text-green-400 hover:bg-green-400/10 rounded transition-colors disabled:opacity-50"
|
|
title="启动"
|
|
>
|
|
<Play className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => deleteRule(rule.id)}
|
|
className="p-2 text-red-400 hover:bg-red-400/10 rounded transition-colors"
|
|
title="删除"
|
|
>
|
|
<Trash2 className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{error && (
|
|
<div className="px-6 py-3 bg-red-500/10 border-t border-red-500/20">
|
|
<p className="text-sm text-red-400">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add Rule Modal */}
|
|
{showAddModal && (
|
|
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-50">
|
|
<div className="bg-dark-800 rounded-xl border border-dark-700 shadow-2xl w-full max-w-md p-6">
|
|
<h3 className="text-lg font-semibold text-white mb-4">添加端口转发规则</h3>
|
|
|
|
{/* Type Selection */}
|
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
|
{[
|
|
{ type: 'local', label: '本地转发', desc: '本地→远程' },
|
|
{ type: 'remote', label: '远程转发', desc: '远程→本地' },
|
|
{ type: 'dynamic', label: '动态 SOCKS5', desc: '代理服务器' },
|
|
].map((t) => (
|
|
<button
|
|
key={t.type}
|
|
onClick={() => setNewRule({ ...newRule, type: t.type as any })}
|
|
className={`p-3 rounded-lg border text-left transition-colors ${
|
|
newRule.type === t.type
|
|
? 'bg-accent-blue/20 border-accent-blue text-white'
|
|
: 'bg-dark-700 border-dark-600 text-gray-400 hover:bg-dark-600'
|
|
}`}
|
|
>
|
|
<div className="text-sm font-medium">{t.label}</div>
|
|
<div className="text-xs opacity-70">{t.desc}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Form Fields */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">规则名称</label>
|
|
<input
|
|
type="text"
|
|
value={newRule.name}
|
|
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
|
|
placeholder="例如: MySQL 转发"
|
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">
|
|
{newRule.type === 'remote' ? '远程端口' : '本地端口'}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={newRule.localPort || ''}
|
|
onChange={(e) => setNewRule({ ...newRule, localPort: parseInt(e.target.value) })}
|
|
placeholder="例如: 3306"
|
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue"
|
|
/>
|
|
</div>
|
|
|
|
{newRule.type !== 'dynamic' && (
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">远程端口</label>
|
|
<input
|
|
type="number"
|
|
value={newRule.remotePort || ''}
|
|
onChange={(e) => setNewRule({ ...newRule, remotePort: parseInt(e.target.value) })}
|
|
placeholder="例如: 3306"
|
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{newRule.type === 'local' && (
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">远程主机</label>
|
|
<input
|
|
type="text"
|
|
value={newRule.remoteHost}
|
|
onChange={(e) => setNewRule({ ...newRule, remoteHost: e.target.value })}
|
|
placeholder="例如: localhost 或 192.168.1.100"
|
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 mt-6">
|
|
<button
|
|
onClick={() => setShowAddModal(false)}
|
|
className="flex-1 px-4 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm font-medium transition-colors"
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
onClick={handleAddRule}
|
|
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 rounded-lg text-sm font-medium transition-colors"
|
|
>
|
|
添加
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|