import { useState } from 'react' import { CheckCircle2, FileUp, FolderOpen, ListTree, Monitor, RefreshCw, Server, Target, Upload, Zap, } from 'lucide-react' import Modal from './Modal' import SftpFileSelectorModal from './SftpFileSelectorModal' import { cancelRemoteTransferTask, createRemoteTransferTask, subscribeRemoteTransferProgress, subscribeUploadProgress, uploadFile, } from '../services/sftp' import type { Connection, ConnectionReachabilityStatus, TransferTaskGroup, TransferTaskItem } from '../types' function aggregate(items: TransferTaskItem[]) { const total = items.length || 1 const progress = Math.round(items.reduce((sum, item) => sum + item.progress, 0) / total) const allDone = items.every((item) => ['success', 'error', 'cancelled'].includes(item.status)) const hasError = items.some((item) => item.status === 'error') const hasRunning = items.some((item) => item.status === 'running' || item.status === 'queued') const status = hasRunning ? 'running' : hasError ? 'error' : allDone ? 'success' : 'queued' return { progress, status: status as TransferTaskGroup['status'] } } function StatusDot({ status }: { status: ConnectionReachabilityStatus | undefined }) { if (status === 'online') return if (status === 'offline') return if (status === 'checking') return return } export default function TransferCenterModal({ open, connections, connectionStatuses, tasks, onClose, onTasksChange, }: { open: boolean connections: Connection[] connectionStatuses: Record tasks: TransferTaskGroup[] onClose: () => void onTasksChange: (updater: TransferTaskGroup[] | ((prev: TransferTaskGroup[]) => TransferTaskGroup[])) => void }) { const [tab, setTab] = useState<'local' | 'remote'>('local') const [targetIds, setTargetIds] = useState(connections.length ? [connections[0].id] : []) const [localFiles, setLocalFiles] = useState(null) const [localTargetPath, setLocalTargetPath] = useState('/usr/local/nginx/html') const [remoteSourceId, setRemoteSourceId] = useState(connections[0]?.id ?? 0) const [remoteSourcePath, setRemoteSourcePath] = useState('/opt/app/build.tar.gz') const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/') const [showBrowser, setShowBrowser] = useState(false) if (!open) return null function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) { // Use functional update to always read latest state, avoiding stale-closure bugs in async callbacks onTasksChange((prev) => prev.map((task) => (task.id === groupId ? updater(task) : task))) } async function handleStartLocal() { if (!localFiles?.length || targetIds.length === 0) return const file = localFiles[0] const groupId = String(Date.now()) const group: TransferTaskGroup = { id: groupId, mode: 'LOCAL_TO_MANY', title: `本地文件批量分发 · ${file.name}`, status: 'running', progress: 0, createdAt: new Date().toLocaleTimeString(), items: targetIds.map((id) => ({ id: `${groupId}-${id}`, label: connections.find((item) => item.id === id)?.name || String(id), status: 'queued', progress: 0, message: '等待上传', })), } onTasksChange((prev) => [group, ...prev]) targetIds.forEach(async (targetId) => { const response = await uploadFile(targetId, localTargetPath, file, { overwrite: true }) const taskId = response.data.taskId updateTaskGroup(groupId, (current) => ({ ...current, items: current.items.map((item) => item.label === connections.find((conn) => conn.id === targetId)?.name ? { ...item, status: 'running', message: '正在传输...', taskId } : item, ), })) const unsubscribe = subscribeUploadProgress(taskId, (task) => { updateTaskGroup(groupId, (current) => { const nextItems = current.items.map((item) => item.taskId === task.taskId ? { ...item, progress: task.progress, status: task.status, message: task.error || (task.status === 'success' ? '上传完成' : task.status === 'error' ? '上传失败' : task.status === 'cancelled' ? '已取消' : '正在传输...'), } : item, ) const next = aggregate(nextItems) return { ...current, items: nextItems, progress: next.progress, status: next.status } }) if (['success', 'error'].includes(task.status)) { unsubscribe() } }) }) } async function handleStartRemote() { if (!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()) return const groupId = String(Date.now()) const sourceName = remoteSourcePath.trim().split('/').filter(Boolean).pop() || remoteSourcePath.trim() const group: TransferTaskGroup = { id: groupId, mode: 'REMOTE_TO_MANY', title: `跨主机分发 · ${sourceName}`, status: 'running', progress: 0, createdAt: new Date().toLocaleTimeString(), items: targetIds.map((id) => ({ id: `${groupId}-${id}`, label: connections.find((item) => item.id === id)?.name || String(id), status: 'queued', progress: 0, message: '等待创建任务', })), } onTasksChange((prev) => [group, ...prev]) targetIds.forEach(async (targetId) => { const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath) const taskId = response.data.taskId updateTaskGroup(groupId, (current) => ({ ...current, items: current.items.map((item) => item.label === connections.find((conn) => conn.id === targetId)?.name ? { ...item, status: 'running', message: '正在跨服同步...', taskId } : item, ), })) const unsubscribe = subscribeRemoteTransferProgress(taskId, (task) => { updateTaskGroup(groupId, (current) => { const nextItems = current.items.map((item) => item.taskId === task.taskId ? { ...item, progress: task.progress, status: task.status, message: task.error || (task.status === 'success' ? '同步完成' : task.status === 'error' ? '同步失败' : task.status === 'cancelled' ? '已取消' : '正在同步...'), } : item, ) const next = aggregate(nextItems) return { ...current, items: nextItems, progress: next.progress, status: next.status } }) if (['success', 'error', 'cancelled'].includes(task.status)) { unsubscribe() } }) }) } return ( <>
{/* Tabs */}
{/* Left panel */}
{tab === 'local' ? (
并发数
按浏览器任务并行执行
3. 目标服务器
{connections.map((server) => { const st = connectionStatuses[server.id] const isOnline = st === 'online' return ( ) })}
) : (
{/* Source config */}

源配置

源服务器 {(() => { const st = connectionStatuses[remoteSourceId] if (st === 'online') return 在线 if (st === 'offline') return 离线 if (st === 'checking') return 检测中 return 未知 })()}
源文件 / 文件夹路径
setRemoteSourcePath(event.target.value)} placeholder="/opt/app" />

📁 支持文件夹传输

选择文件夹时,将在目标路径下创建同名子目录并递归传输所有内容。

{/* Target config */}

目标配置

目标服务器
{connections.map((server) => { const st = connectionStatuses[server.id] const isOnline = st === 'online' return ( ) })}
)}
{/* Task status panel */}

任务状态

{tasks.length === 0 ? (

暂无传输任务

) : ( tasks.map((task) => (
{task.title} {task.status === 'running' ? ( ) : null}
{task.createdAt}
{task.items.map((item) => (
{item.label}
{item.message} {item.progress}%
{item.status === 'success' ? ( ) : item.status === 'running' ? ( ) : null}
))}
)) )}
{/* SFTP file selector popup — mounted outside the main modal so it overlays on top */} {showBrowser && ( c.id === remoteSourceId) ?? connections[0]} onSelect={(path) => { setRemoteSourcePath(path) setShowBrowser(false) }} onClose={() => setShowBrowser(false)} /> )} ) }