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 */} setTab('local')} > 本地分发到多台 { setTab('remote'); setShowBrowser(false) }} > 远程分发到多台 {/* Left panel */} {tab === 'local' ? ( 1. 选择本地文件 setLocalFiles(event.target.files)} className="mx-auto block text-sm text-slate-300" /> {localFiles?.[0]?.name || '支持多选文件/文件夹'} 2. 目标路径 setLocalTargetPath(event.target.value)} /> 并发数 按浏览器任务并行执行 3. 目标服务器 {connections.map((server) => { const st = connectionStatuses[server.id] const isOnline = st === 'online' return ( setTargetIds((prev) => prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id], ) } /> {server.name} ) })} void handleStartLocal()} > {!localFiles?.length ? '请选择本地文件' : targetIds.length === 0 ? '请选择目标服务器' : '开始分发'} ) : ( {/* Source config */} 源配置 源服务器 {(() => { const st = connectionStatuses[remoteSourceId] if (st === 'online') return 在线 if (st === 'offline') return 离线 if (st === 'checking') return 检测中 return 未知 })()} { setRemoteSourceId(Number(event.target.value)) setShowBrowser(false) }} > {connections.map((server) => { const st = connectionStatuses[server.id] const isOnline = st === 'online' const label = st === 'online' ? '● ' : st === 'offline' ? '● ' : st === 'checking' ? '◔ ' : '◦ ' return ( {label}{server.name}{!isOnline ? (st === 'offline' ? ' (离线)' : ' (未知)') : ''} ) })} 源文件 / 文件夹路径 setShowBrowser((v) => !v)} > {showBrowser ? '收起' : '浏览...'} setRemoteSourcePath(event.target.value)} placeholder="/opt/app" /> 📁 支持文件夹传输 选择文件夹时,将在目标路径下创建同名子目录并递归传输所有内容。 {/* Target config */} 目标配置 目标目录 setRemoteTargetPath(event.target.value)} /> 目标服务器 setTargetIds(connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id))} > 全选在线 {connections.map((server) => { const st = connectionStatuses[server.id] const isOnline = st === 'online' return ( setTargetIds((prev) => prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id], ) } /> {server.name} ) })} void handleStartRemote()} > {!remoteSourceId ? '请选择源服务器' : !remoteSourcePath.trim() ? '请填写源路径' : targetIds.length === 0 ? '请选择目标服务器' : '跨服同步分发'} )} {/* Task status panel */} 任务状态 onTasksChange([])}> 清空记录 {tasks.length === 0 ? ( 暂无传输任务 ) : ( tasks.map((task) => ( {task.title} {task.status === 'running' ? ( { task.items.forEach((item) => { if (item.taskId) void cancelRemoteTransferTask(item.taskId) }) }} > 取消 ) : 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)} /> )} > ) }
📁 支持文件夹传输
选择文件夹时,将在目标路径下创建同名子目录并递归传输所有内容。
暂无传输任务