522 lines
25 KiB
TypeScript
522 lines
25 KiB
TypeScript
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 <span className="h-2 w-2 shrink-0 rounded-full bg-emerald-400" title="在线" />
|
||
if (status === 'offline')
|
||
return <span className="h-2 w-2 shrink-0 rounded-full bg-red-400" title="离线" />
|
||
if (status === 'checking')
|
||
return <span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-yellow-400" title="检测中" />
|
||
return <span className="h-2 w-2 shrink-0 rounded-full bg-slate-600" title="未知" />
|
||
}
|
||
|
||
|
||
|
||
export default function TransferCenterModal({
|
||
open,
|
||
connections,
|
||
connectionStatuses,
|
||
tasks,
|
||
onClose,
|
||
onTasksChange,
|
||
}: {
|
||
open: boolean
|
||
connections: Connection[]
|
||
connectionStatuses: Record<number, ConnectionReachabilityStatus>
|
||
tasks: TransferTaskGroup[]
|
||
onClose: () => void
|
||
onTasksChange: (updater: TransferTaskGroup[] | ((prev: TransferTaskGroup[]) => TransferTaskGroup[])) => void
|
||
}) {
|
||
const [tab, setTab] = useState<'local' | 'remote'>('local')
|
||
const [targetIds, setTargetIds] = useState<number[]>(connections.length ? [connections[0].id] : [])
|
||
const [localFiles, setLocalFiles] = useState<FileList | null>(null)
|
||
const [localTargetPath, setLocalTargetPath] = useState('/usr/local/nginx/html')
|
||
const [remoteSourceId, setRemoteSourceId] = useState<number>(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 (
|
||
<>
|
||
<Modal title="文件传输中心" onClose={onClose} maxWidth="max-w-7xl">
|
||
<div className="flex h-[74vh] flex-col overflow-hidden rounded-2xl border border-slate-800 bg-[#0d1117]">
|
||
{/* Tabs */}
|
||
<div className="flex gap-8 border-b border-slate-800 bg-slate-900 px-6 pt-4">
|
||
<button
|
||
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'local' ? 'border-blue-500 text-blue-400' : 'border-transparent text-slate-400'}`}
|
||
onClick={() => setTab('local')}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<Monitor size={16} />
|
||
本地分发到多台
|
||
</span>
|
||
</button>
|
||
<button
|
||
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'remote' ? 'border-purple-500 text-purple-400' : 'border-transparent text-slate-400'}`}
|
||
onClick={() => { setTab('remote'); setShowBrowser(false) }}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<Server size={16} />
|
||
远程分发到多台
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* Left panel */}
|
||
<div className="flex flex-1 overflow-hidden border-r border-slate-800 bg-slate-900/70 p-6">
|
||
{tab === 'local' ? (
|
||
<div className="flex flex-1 flex-col space-y-6">
|
||
<label className="space-y-2">
|
||
<span className="text-sm text-slate-300">1. 选择本地文件</span>
|
||
<div className="rounded-2xl border-2 border-dashed border-slate-600 bg-slate-800/40 p-6 text-center">
|
||
<input type="file" onChange={(event) => setLocalFiles(event.target.files)} className="mx-auto block text-sm text-slate-300" />
|
||
<div className="mt-2 text-xs text-slate-500">{localFiles?.[0]?.name || '支持多选文件/文件夹'}</div>
|
||
</div>
|
||
</label>
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
<label className="space-y-2">
|
||
<span className="text-sm text-slate-300">2. 目标路径</span>
|
||
<input className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white" value={localTargetPath} onChange={(event) => setLocalTargetPath(event.target.value)} />
|
||
</label>
|
||
<div className="space-y-2">
|
||
<span className="text-sm text-slate-300">并发数</span>
|
||
<div className="rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-slate-400">按浏览器任务并行执行</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 space-y-2">
|
||
<span className="text-sm text-slate-300">3. 目标服务器</span>
|
||
<div className="rounded-2xl border border-slate-700 bg-black p-2">
|
||
{connections.map((server) => {
|
||
const st = connectionStatuses[server.id]
|
||
const isOnline = st === 'online'
|
||
return (
|
||
<label
|
||
key={server.id}
|
||
className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
|
||
isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
|
||
}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
disabled={!isOnline}
|
||
checked={targetIds.includes(server.id)}
|
||
onChange={() =>
|
||
setTargetIds((prev) =>
|
||
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id],
|
||
)
|
||
}
|
||
/>
|
||
<StatusDot status={st} />
|
||
<Server size={14} className={isOnline ? 'text-emerald-500' : 'text-slate-600'} />
|
||
<span className="text-sm text-slate-300">{server.name}</span>
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
|
||
!localFiles?.length || targetIds.length === 0
|
||
? 'cursor-not-allowed bg-slate-800 text-slate-500'
|
||
: 'bg-blue-600 text-white hover:bg-blue-500'
|
||
}`}
|
||
disabled={!localFiles?.length || targetIds.length === 0}
|
||
onClick={() => void handleStartLocal()}
|
||
>
|
||
<Upload size={18} />
|
||
{!localFiles?.length ? '请选择本地文件' : targetIds.length === 0 ? '请选择目标服务器' : '开始分发'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-full flex-1 gap-6 overflow-hidden">
|
||
{/* Source config */}
|
||
<div className="flex w-1/2 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 pr-6">
|
||
<h3 className="flex items-center gap-2 text-sm font-bold text-purple-400">
|
||
<Zap size={16} />
|
||
源配置
|
||
</h3>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-slate-300">源服务器</span>
|
||
{(() => {
|
||
const st = connectionStatuses[remoteSourceId]
|
||
if (st === 'online') return <span className="flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs font-medium text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />在线</span>
|
||
if (st === 'offline') return <span className="flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-medium text-red-400"><span className="h-1.5 w-1.5 rounded-full bg-red-400" />离线</span>
|
||
if (st === 'checking') return <span className="flex items-center gap-1 rounded-full bg-yellow-500/15 px-2 py-0.5 text-xs font-medium text-yellow-400"><span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400" />检测中</span>
|
||
return <span className="flex items-center gap-1 rounded-full bg-slate-700/60 px-2 py-0.5 text-xs font-medium text-slate-500"><span className="h-1.5 w-1.5 rounded-full bg-slate-500" />未知</span>
|
||
})()}
|
||
</div>
|
||
<select
|
||
className={`w-full rounded-xl border bg-black px-4 py-3 text-sm text-white ${
|
||
connectionStatuses[remoteSourceId] === 'online'
|
||
? 'border-emerald-700'
|
||
: connectionStatuses[remoteSourceId] === 'offline'
|
||
? 'border-red-800'
|
||
: 'border-slate-700'
|
||
}`}
|
||
value={remoteSourceId}
|
||
onChange={(event) => {
|
||
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 (
|
||
<option key={server.id} value={server.id} disabled={!isOnline}>
|
||
{label}{server.name}{!isOnline ? (st === 'offline' ? ' (离线)' : ' (未知)') : ''}
|
||
</option>
|
||
)
|
||
})}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-slate-300">源文件 / 文件夹路径</span>
|
||
<button
|
||
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-xs transition ${
|
||
showBrowser
|
||
? 'bg-purple-600 text-white'
|
||
: 'border border-slate-700 text-slate-400 hover:border-purple-500 hover:text-purple-400'
|
||
}`}
|
||
onClick={() => setShowBrowser((v) => !v)}
|
||
>
|
||
<FolderOpen size={12} />
|
||
{showBrowser ? '收起' : '浏览...'}
|
||
</button>
|
||
</div>
|
||
<input
|
||
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 font-mono text-sm text-white"
|
||
value={remoteSourcePath}
|
||
onChange={(event) => setRemoteSourcePath(event.target.value)}
|
||
placeholder="/opt/app"
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-auto rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 text-xs text-slate-500">
|
||
<p className="font-medium text-slate-400">📁 支持文件夹传输</p>
|
||
<p className="mt-1">选择文件夹时,将在目标路径下创建同名子目录并递归传输所有内容。</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Target config */}
|
||
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-hidden">
|
||
<h3 className="flex items-center gap-2 text-sm font-bold text-blue-400">
|
||
<Target size={16} />
|
||
目标配置
|
||
</h3>
|
||
|
||
<label className="space-y-2">
|
||
<span className="text-sm text-slate-300">目标目录</span>
|
||
<input
|
||
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white"
|
||
value={remoteTargetPath}
|
||
onChange={(event) => setRemoteTargetPath(event.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||
<div className="flex shrink-0 items-center justify-between">
|
||
<span className="text-sm text-slate-300">目标服务器</span>
|
||
<button
|
||
className="text-xs text-blue-400"
|
||
onClick={() => setTargetIds(connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id))}
|
||
>
|
||
全选在线
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
|
||
{connections.map((server) => {
|
||
const st = connectionStatuses[server.id]
|
||
const isOnline = st === 'online'
|
||
return (
|
||
<label
|
||
key={server.id}
|
||
className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
|
||
isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
|
||
}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
disabled={!isOnline}
|
||
checked={targetIds.includes(server.id)}
|
||
onChange={() =>
|
||
setTargetIds((prev) =>
|
||
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id],
|
||
)
|
||
}
|
||
/>
|
||
<StatusDot status={st} />
|
||
<span className="text-sm text-slate-300">{server.name}</span>
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
|
||
!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()
|
||
? 'cursor-not-allowed bg-slate-800 text-slate-500'
|
||
: 'bg-purple-600 text-white hover:bg-purple-500'
|
||
}`}
|
||
disabled={!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()}
|
||
onClick={() => void handleStartRemote()}
|
||
>
|
||
<Zap size={18} fill="currentColor" />
|
||
{!remoteSourceId
|
||
? '请选择源服务器'
|
||
: !remoteSourcePath.trim()
|
||
? '请填写源路径'
|
||
: targetIds.length === 0
|
||
? '请选择目标服务器'
|
||
: '跨服同步分发'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Task status panel */}
|
||
<div className="flex w-[360px] shrink-0 flex-col bg-[#0d1117]">
|
||
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-4">
|
||
<h4 className="flex items-center gap-2 text-sm font-medium text-slate-200">
|
||
<ListTree size={16} className="text-slate-400" />
|
||
任务状态
|
||
</h4>
|
||
<button className="text-xs text-slate-400" onClick={() => onTasksChange([])}>
|
||
清空记录
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 space-y-4 overflow-auto p-3">
|
||
{tasks.length === 0 ? (
|
||
<div className="flex h-full flex-col items-center justify-center text-slate-500">
|
||
<FileUp size={32} className="mb-2 opacity-50" />
|
||
<p className="text-sm">暂无传输任务</p>
|
||
</div>
|
||
) : (
|
||
tasks.map((task) => (
|
||
<div key={task.id} className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-800">
|
||
<div className="border-b border-slate-700 bg-slate-800/90 p-3">
|
||
<div className="mb-2 flex items-start justify-between">
|
||
<span className="pr-2 text-sm font-medium text-slate-100">{task.title}</span>
|
||
{task.status === 'running' ? (
|
||
<button
|
||
className="shrink-0 text-xs text-red-400"
|
||
onClick={() => {
|
||
task.items.forEach((item) => {
|
||
if (item.taskId) void cancelRemoteTransferTask(item.taskId)
|
||
})
|
||
}}
|
||
>
|
||
取消
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="text-xs text-slate-500">
|
||
{task.createdAt}
|
||
</div>
|
||
</div>
|
||
<div className="max-h-48 space-y-1 overflow-auto bg-slate-900/60 p-2">
|
||
{task.items.map((item) => (
|
||
<div key={item.id} className="rounded-xl p-2 hover:bg-slate-800">
|
||
<div className="flex justify-between text-xs">
|
||
<span className="truncate text-slate-300">{item.label}</span>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-slate-500">{item.message}</span>
|
||
<span className={item.status === 'success' ? 'text-emerald-400' : 'text-blue-400'}>{item.progress}%</span>
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<div className="h-1 flex-1 rounded-full bg-black">
|
||
<div
|
||
className={`h-1 rounded-full ${item.status === 'success' ? 'bg-emerald-500' : item.status === 'error' ? 'bg-red-500' : 'bg-blue-400'}`}
|
||
style={{ width: `${item.progress}%` }}
|
||
/>
|
||
</div>
|
||
{item.status === 'success' ? (
|
||
<CheckCircle2 size={12} className="text-emerald-500" />
|
||
) : item.status === 'running' ? (
|
||
<RefreshCw size={12} className="animate-spin text-blue-400" />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* SFTP file selector popup — mounted outside the main modal so it overlays on top */}
|
||
{showBrowser && (
|
||
<SftpFileSelectorModal
|
||
open={showBrowser}
|
||
connection={connections.find((c) => c.id === remoteSourceId) ?? connections[0]}
|
||
onSelect={(path) => {
|
||
setRemoteSourcePath(path)
|
||
setShowBrowser(false)
|
||
}}
|
||
onClose={() => setShowBrowser(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|