v1.3.0: 主题系统+代码分割+设置重构+命令片段库+命令面板+连接面板 | 19 files +2141/-257 | tsc零错误 构建成功
This commit is contained in:
1 parent
caba5f4c0d
commit
1f2de032a4
19 files changed
+2142
-258
No files matched your search
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* ConnectionPanel - 连接状态管理面板
|
||||
*
|
||||
* 右侧滑出面板,实时监控所有连接状态、流量统计、支持重连/断开操作。
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
RefreshCw,
|
||||
X,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Activity,
|
||||
} from 'lucide-react'
|
||||
import { useConnectionStore } from '../stores/connection'
|
||||
import { usePoolStore } from '../stores/connection/poolStore'
|
||||
import { useConnectionPanelStore } from '../stores/connectionPanelStore'
|
||||
import type { PooledConnection, ConnectionState } from '../stores/connection/types'
|
||||
import EmptyState from './common/EmptyState'
|
||||
|
||||
// ============== Helpers ==============
|
||||
|
||||
/** 格式化字节 */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const value = bytes / Math.pow(k, i)
|
||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
/** 格式化连接时长 (ms → readable) */
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
}
|
||||
|
||||
// ============== Status Dot ==============
|
||||
|
||||
interface StatusDotProps {
|
||||
state: ConnectionState
|
||||
}
|
||||
|
||||
const STATUS_DOT_COLORS: Record<ConnectionState, string> = {
|
||||
connected: 'bg-green-400',
|
||||
connecting: 'bg-yellow-400 animate-pulse',
|
||||
background: 'bg-green-400 opacity-60',
|
||||
suspended: 'bg-gray-400',
|
||||
error: 'bg-red-400',
|
||||
disconnected: 'bg-gray-500',
|
||||
reconnecting: 'bg-yellow-400 animate-pulse',
|
||||
}
|
||||
|
||||
function StatusDot({ state }: StatusDotProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${STATUS_DOT_COLORS[state] ?? 'bg-gray-500'}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ============== Status Label ==============
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
background: '后台',
|
||||
suspended: '已挂起',
|
||||
error: '错误',
|
||||
disconnected: '已断开',
|
||||
reconnecting: '重连中',
|
||||
}
|
||||
|
||||
// ============== Connection Card ==============
|
||||
|
||||
interface ConnectionCardProps {
|
||||
conn: PooledConnection
|
||||
onReconnect: (id: string) => void
|
||||
onDisconnect: (id: string) => void
|
||||
reconnectingId: string | null
|
||||
}
|
||||
|
||||
function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) {
|
||||
const isReconnecting = reconnectingId === conn.connectionId
|
||||
const canReconnect =
|
||||
conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
|
||||
const canDisconnect =
|
||||
conn.state !== 'disconnected' && conn.state !== 'error'
|
||||
|
||||
const durationMs = Date.now() - conn.stats.connectedAt
|
||||
const sessionCount = conn.shellIds.size + conn.sftpIds.size
|
||||
|
||||
return (
|
||||
<div className="bg-dark-900 rounded-lg p-3 space-y-2.5">
|
||||
{/* Header: host + status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot state={conn.state} />
|
||||
<span className="text-sm text-gray-200 truncate font-medium">
|
||||
{conn.config.username}@{conn.config.host}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
conn.state === 'connected'
|
||||
? 'bg-green-400/10 text-green-400'
|
||||
: conn.state === 'connecting' || conn.state === 'reconnecting'
|
||||
? 'bg-yellow-400/10 text-yellow-400'
|
||||
: conn.state === 'background'
|
||||
? 'bg-green-400/10 text-green-400 opacity-70'
|
||||
: conn.state === 'suspended'
|
||||
? 'bg-gray-400/10 text-gray-400'
|
||||
: conn.state === 'error'
|
||||
? 'bg-red-400/10 text-red-400'
|
||||
: 'bg-gray-500/10 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[conn.state]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-400">
|
||||
{/* Port */}
|
||||
<span className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
:{conn.config.port ?? 22}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDuration(durationMs)}
|
||||
</span>
|
||||
|
||||
{/* Transfer */}
|
||||
{conn.stats.bytesTransferred > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
{formatBytes(conn.stats.bytesTransferred)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Sessions */}
|
||||
{sessionCount > 0 && (
|
||||
<span>
|
||||
{conn.shellIds.size} shell{conn.sftpIds.size > 0 ? ` / ${conn.sftpIds.size} sftp` : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Ref count */}
|
||||
{conn.refCount > 1 && <span>×{conn.refCount} 引用</span>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{canReconnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReconnect(conn.connectionId)}
|
||||
disabled={isReconnecting}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs
|
||||
bg-accent-blue/10 text-accent-blue hover:bg-accent-blue/20
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
|
||||
{isReconnecting ? '重连中...' : '重连'}
|
||||
</button>
|
||||
)}
|
||||
{canDisconnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDisconnect(conn.connectionId)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs
|
||||
bg-red-400/10 text-red-400 hover:bg-red-400/20 transition-colors"
|
||||
>
|
||||
<WifiOff className="w-3 h-3" />
|
||||
断开
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{conn.error && (
|
||||
<p className="text-xs text-red-400 truncate" title={conn.error}>
|
||||
{conn.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============== Stats Overview ==============
|
||||
|
||||
interface StatsBarProps {
|
||||
total: number
|
||||
connected: number
|
||||
background: number
|
||||
error: number
|
||||
}
|
||||
|
||||
function StatsOverview({ total, connected, background, error }: StatsBarProps) {
|
||||
const items = [
|
||||
{ label: '总计', value: total, color: 'text-gray-300' },
|
||||
{ label: '活跃', value: connected, color: 'text-green-400' },
|
||||
{ label: '后台', value: background, color: 'text-yellow-400' },
|
||||
{ label: '断开/错误', value: error, color: 'text-red-400' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="bg-dark-900 rounded-lg p-2 text-center">
|
||||
<div className={`text-lg font-semibold ${item.color}`}>{item.value}</div>
|
||||
<div className="text-[10px] text-gray-500">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============== Main Panel ==============
|
||||
|
||||
export default function ConnectionPanel() {
|
||||
const isOpen = useConnectionPanelStore((s) => s.isOpen)
|
||||
const close = useConnectionPanelStore((s) => s.close)
|
||||
|
||||
const connections = usePoolStore((s) => s.connections)
|
||||
const {
|
||||
resumeConnection,
|
||||
disconnectConnection,
|
||||
getStats,
|
||||
} = useConnectionStore()
|
||||
|
||||
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
|
||||
|
||||
// Derive connections list and stats
|
||||
const { connList, stats } = useMemo(() => {
|
||||
const list = Array.from(connections.values())
|
||||
const s = getStats()
|
||||
return { connList: list, stats: s }
|
||||
}, [connections, getStats])
|
||||
|
||||
// Handlers
|
||||
const handleReconnect = useCallback(
|
||||
async (connectionId: string) => {
|
||||
setReconnectingId(connectionId)
|
||||
try {
|
||||
await resumeConnection(connectionId)
|
||||
} finally {
|
||||
setReconnectingId(null)
|
||||
}
|
||||
},
|
||||
[resumeConnection],
|
||||
)
|
||||
|
||||
const handleDisconnect = useCallback(
|
||||
async (connectionId: string) => {
|
||||
await disconnectConnection(connectionId)
|
||||
},
|
||||
[disconnectConnection],
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-40"
|
||||
onClick={close}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className="fixed top-0 right-0 bottom-0 w-[360px] z-50
|
||||
bg-dark-800/95 backdrop-blur border-l border-dark-700
|
||||
flex flex-col shadow-xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-gray-200 flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-accent-blue" />
|
||||
连接管理
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="p-1 rounded hover:bg-dark-700 text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="px-4 py-3 border-b border-dark-700 shrink-0">
|
||||
<StatsOverview
|
||||
total={stats.total}
|
||||
connected={stats.connected}
|
||||
background={stats.background}
|
||||
error={stats.error + stats.suspended}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Connection List */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
|
||||
{connList.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={WifiOff}
|
||||
title="暂无连接"
|
||||
description="当前没有任何活跃连接,连接到服务器后将在此显示。"
|
||||
/>
|
||||
) : (
|
||||
connList.map((conn) => (
|
||||
<ConnectionCard
|
||||
key={conn.connectionId}
|
||||
conn={conn}
|
||||
onReconnect={handleReconnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
reconnectingId={reconnectingId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user