feat: rebuild frontend with react

This commit is contained in:
liumangmang
2026-04-22 09:53:06 +08:00
parent 42836aa4c3
commit 423cca97a6
81 changed files with 2907 additions and 10230 deletions
@@ -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>
)
}