Files
opsxshell/src/components/PortForwardView.tsx
T
OpsXShell Dev e9a0d21758 feat(theme): 专业主题系统重构 — Warp/Linear 设计参考,深色/浅色完整适配
参考设计系统:
- Warp: 温暖暗色调、自然层次、克制配色
- Linear: 亮度阶梯层次、半透明边框、精确排版

核心改进:
- app-theme.ts: 完整重写深色/浅色调色板 (+surface-hover/active, border-subtle/focus, feedback-bg, placeholder)
- useTheme.ts: 修复硬编码 return 'dark',支持 dark/light/system 完整切换
- index.css: 消除硬编码颜色,全部使用 CSS 变量
- tailwind.config.js: 新增 txt/content/surface/border/ph/feedback 语义 token
- 44 文件批量迁移: 292 处 text-gray/bg-gray/text-white/placeholder-gray → 主题感知语义 token

| 44 files +611/-482 | tsc零错误 | 96/96 tests passing
2026-04-25 19:31:51 +08:00

425 lines
16 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'
import { useConfirmDialog } from './common/ConfirmDialog'
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, updateTab } = useTabStore()
const pool = useConnectionPool()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const currentTab = tabs.find(t => t.id === tabId)
const needsReconnect = currentTab?.needsReconnect
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: unknown) {
setError((err instanceof Error ? err.message : String(err)) || '启动失败')
} 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 = async (ruleId: string) => {
const confirmed = await confirm({
title: '确认删除',
message: '确定删除此转发规则?',
danger: true,
confirmText: '删除',
})
if (confirmed) {
saveRules(rules.filter(r => r.id !== ruleId))
}
}
return (
<>
<div className="h-full flex flex-col bg-dark-900">
{/* 会话恢复 - 需要重新连接覆盖层 */}
{needsReconnect && currentTab?.sessionId && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-50">
<div className="text-center">
<div className="w-16 h-16 bg-surface-baseccent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<ArrowRightLeft className="w-8 h-8 text-accent-blue" />
</div>
<div className="text-content-primary mb-2 text-lg">会话已恢复</div>
<div className="text-txt-muted text-sm mb-6">
上次关闭时的端口转发标签已恢复<br />
需要重新建立 SSH 连接才能使用
</div>
<button
onClick={() => {
updateTab(tabId, { needsReconnect: false })
// 刷新页面或触发连接逻辑
}}
className="px-6 py-2.5 bg-surface-baseccent-blue hover:bg-surface-baselue-500 text-content-primary rounded-lg transition-colors"
>
重新连接
</button>
</div>
</div>
)}
{/* 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-content-primary">端口转发</h2>
<p className="text-sm text-txt-muted">
{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-surface-baseccent-blue hover:bg-surface-baselue-600 disabled:bg-dark-700 disabled:text-txt-muted 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-txt-muted">
<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-surface-baselue-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-content-primary">{rule.name}</span>
<span className={`text-xs px-2 py-0.5 rounded ${
rule.type === 'local' ? 'bg-surface-baselue-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-txt-muted 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-content-primary 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 'local' | 'remote' | 'dynamic' })}
className={`p-3 rounded-lg border text-left transition-colors ${
newRule.type === t.type
? 'bg-surface-baseccent-blue/20 border-accent-blue text-content-primary'
: 'bg-dark-700 border-dark-600 text-txt-muted 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-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
/>
</div>
{newRule.type !== 'dynamic' && (
<div>
<label className="block text-sm text-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
/>
</div>
)}
</div>
{newRule.type === 'local' && (
<div>
<label className="block text-sm text-txt-muted 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-content-primary placeholder-ph 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-surface-baseccent-blue hover:bg-surface-baselue-600 rounded-lg text-sm font-medium transition-colors"
>
添加
</button>
</div>
</div>
</div>
)}
</div>
{ConfirmDialogComponent}
</>
)
}