feat: rebuild frontend with react
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, FileUp, ListTree, Monitor, RefreshCw, Server, Target, Upload, Zap } from 'lucide-react'
|
||||
import Modal from './Modal'
|
||||
import {
|
||||
cancelRemoteTransferTask,
|
||||
createRemoteTransferTask,
|
||||
subscribeRemoteTransferProgress,
|
||||
subscribeUploadProgress,
|
||||
uploadFile,
|
||||
} from '../services/sftp'
|
||||
import type { Connection, 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'] }
|
||||
}
|
||||
|
||||
export default function TransferCenterModal({
|
||||
open,
|
||||
connections,
|
||||
tasks,
|
||||
onClose,
|
||||
onTasksChange,
|
||||
}: {
|
||||
open: boolean
|
||||
connections: Connection[]
|
||||
tasks: TransferTaskGroup[]
|
||||
onClose: () => void
|
||||
onTasksChange: (tasks: 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/')
|
||||
|
||||
if (!open) return null
|
||||
|
||||
function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) {
|
||||
onTasksChange(tasks.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([group, ...tasks])
|
||||
|
||||
targetIds.forEach(async (targetId) => {
|
||||
const response = await uploadFile(targetId, localTargetPath, file)
|
||||
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' ? '上传完成' : '正在传输...'),
|
||||
}
|
||||
: 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 group: TransferTaskGroup = {
|
||||
id: groupId,
|
||||
mode: 'REMOTE_TO_MANY',
|
||||
title: '跨主机文件同步',
|
||||
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([group, ...tasks])
|
||||
|
||||
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' ? '同步完成' : '正在同步...'),
|
||||
}
|
||||
: 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]">
|
||||
<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')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Server size={16} />
|
||||
远程分发到多台
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 overflow-auto 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) => (
|
||||
<label key={server.id} className="flex items-center gap-2 rounded-xl px-3 py-2 hover:bg-slate-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targetIds.includes(server.id)}
|
||||
onChange={() =>
|
||||
setTargetIds((prev) =>
|
||||
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Server size={14} className="text-slate-500" />
|
||||
<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 bg-blue-600 py-3 font-medium text-white transition hover:bg-blue-500" onClick={() => void handleStartLocal()}>
|
||||
<Upload size={18} />
|
||||
开始分发
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 gap-6">
|
||||
<div className="flex w-1/2 flex-col gap-5 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>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm text-slate-300">源服务器</span>
|
||||
<select className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white" value={remoteSourceId} onChange={(event) => setRemoteSourceId(Number(event.target.value))}>
|
||||
{connections.map((server) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col space-y-2">
|
||||
<span className="text-sm text-slate-300">源文件路径</span>
|
||||
<textarea className="min-h-[180px] flex-1 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)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex w-1/2 flex-col gap-5">
|
||||
<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-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-300">目标服务器</span>
|
||||
<button className="text-xs text-blue-400" onClick={() => setTargetIds(connections.map((item) => item.id))}>
|
||||
全选
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-700 bg-black p-2">
|
||||
{connections.map((server) => (
|
||||
<label key={server.id} className="flex items-center gap-2 rounded-xl px-3 py-2 hover:bg-slate-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targetIds.includes(server.id)}
|
||||
onChange={() =>
|
||||
setTargetIds((prev) =>
|
||||
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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 bg-purple-600 py-3 font-medium text-white transition hover:bg-purple-500" onClick={() => void handleStartRemote()}>
|
||||
<Zap size={18} fill="currentColor" />
|
||||
跨服同步分发
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="mb-1 flex justify-between text-xs text-slate-500">
|
||||
<span>{task.createdAt}</span>
|
||||
<span className={task.status === 'success' ? 'text-emerald-400' : 'text-blue-400'}>{task.progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full border border-slate-700 bg-slate-950">
|
||||
<div className={`h-1.5 rounded-full ${task.status === 'success' ? 'bg-emerald-500' : 'bg-blue-500'}`} style={{ width: `${task.progress}%` }} />
|
||||
</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>
|
||||
<span className="text-slate-500">{item.message}</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user