Please provide the code changes or file diffs you would like me to summarize.

This commit is contained in:
liumangmang
2026-05-07 13:43:31 +08:00
parent f24d0f69ed
commit 3f0ebe24e0
8 changed files with 713 additions and 310 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"java.compile.nullAnalysis.mode": "automatic"
}
+11 -6
View File
@@ -1,14 +1,15 @@
.PHONY: help build up down restart logs ps
.PHONY: help build up up-build down restart logs ps
COMPOSE_FILE := docker/docker-compose.yml
COMPOSE := docker compose -f $(COMPOSE_FILE)
help:
@printf "Available targets:\n"
@printf " make build Build Docker images\n"
@printf " make up Build and start services in background\n"
@printf " make build Build Docker images (with cache)\n"
@printf " make up Start services (NO rebuild, reuse existing image)\n"
@printf " make up-build Rebuild image then start (use after code changes)\n"
@printf " make down Stop and remove services\n"
@printf " make restart Restart services\n"
@printf " make restart Restart services without rebuild\n"
@printf " make logs Follow service logs\n"
@printf " make ps Show service status\n"
@printf " Note: do not use 'docker compose down -v' in daily usage (it removes persistent volumes)\n"
@@ -16,7 +17,12 @@ help:
build:
$(COMPOSE) build
# 日常启动:直接用已有镜像,不重新构建(秒启动)
up:
$(COMPOSE) up -d
# 代码有改动时使用:重新构建镜像再启动
up-build:
$(COMPOSE) build
$(COMPOSE) up -d
@@ -24,8 +30,7 @@ down:
$(COMPOSE) down
restart:
$(COMPOSE) down
$(COMPOSE) up -d
$(COMPOSE) restart
logs:
$(COMPOSE) logs -f
@@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicLong;
@Service
public class SftpService {
private ExecutorService executorService = Executors.newFixedThreadPool(2);
private ExecutorService executorService = Executors.newCachedThreadPool();
public void setExecutorService(ExecutorService executorService) {
this.executorService = executorService;
@@ -231,8 +231,9 @@ public class SftpService {
}
/**
* Transfer a single file from source session to target session (streaming, no full file in memory).
* Fails if sourcePath is a directory.
* Transfer a file or directory from source session to target session.
* If sourcePath is a directory, transfers recursively (the source directory itself is
* recreated under targetPath — "plan A" / scp -r behaviour).
*/
public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath)
throws Exception {
@@ -246,8 +247,161 @@ public class SftpService {
TransferProgressListener progressListener) throws Exception {
SftpATTRS attrs = source.getChannel().stat(sourcePath);
if (attrs.isDir()) {
throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported");
final long totalBytes = calculateTotalSize(source, sourcePath);
if (progressListener != null) {
progressListener.onStart(totalBytes);
}
AtomicLong transferred = new AtomicLong(0);
transferDirectoryRemote(source, sourcePath, target, targetPath, totalBytes, transferred, progressListener);
} else {
String finalTargetPath = targetPath;
try {
SftpATTRS targetAttrs = target.getChannel().stat(targetPath);
if (targetAttrs.isDir()) {
String fileName = sourcePath.contains("/") ? sourcePath.substring(sourcePath.lastIndexOf('/') + 1) : sourcePath;
finalTargetPath = targetPath.endsWith("/") ? targetPath + fileName : targetPath + "/" + fileName;
}
} catch (SftpException e) {
if (targetPath.endsWith("/")) {
String fileName = sourcePath.contains("/") ? sourcePath.substring(sourcePath.lastIndexOf('/') + 1) : sourcePath;
finalTargetPath = targetPath + fileName;
}
}
transferSingleFileRemote(source, sourcePath, target, finalTargetPath, progressListener);
}
}
/** Recursively calculate total byte size of all files under a remote path. */
private long calculateTotalSize(SftpSession session, String path) throws Exception {
SftpATTRS attrs = session.getChannel().stat(path);
if (!attrs.isDir()) {
return attrs.getSize();
}
long total = 0;
Vector<?> entries = session.getChannel().ls(path);
for (Object obj : entries) {
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
String name = entry.getFilename();
if (".".equals(name) || "..".equals(name)) continue;
String childPath = path.endsWith("/") ? path + name : path + "/" + name;
total += calculateTotalSize(session, childPath);
}
return total;
}
/** Recursively create a directory and its parents on a remote session (mkdir -p). */
private void mkdirRecursive(SftpSession session, String path) throws Exception {
try {
SftpATTRS attrs = session.getChannel().stat(path);
if (attrs.isDir()) return;
throw new IllegalStateException("Path exists but is not a directory: " + path);
} catch (SftpException e) {
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) throw e;
}
int slash = path.lastIndexOf('/');
if (slash > 0) {
mkdirRecursive(session, path.substring(0, slash));
}
try {
session.getChannel().mkdir(path);
} catch (SftpException e) {
// Another thread may have created it concurrently; SSH_FX_FAILURE is returned in that case
if (e.id != ChannelSftp.SSH_FX_FAILURE) throw e;
}
}
/**
* Recursively transfer a directory.
* The source directory name is recreated under targetParentPath (plan A / scp -r behaviour).
*/
private void transferDirectoryRemote(SftpSession source,
String sourceDirPath,
SftpSession target,
String targetParentPath,
long totalBytes,
AtomicLong transferred,
TransferProgressListener progressListener) throws Exception {
String dirName = sourceDirPath.contains("/")
? sourceDirPath.substring(sourceDirPath.lastIndexOf('/') + 1)
: sourceDirPath;
String targetDirPath = targetParentPath.endsWith("/")
? targetParentPath + dirName
: targetParentPath + "/" + dirName;
mkdirRecursive(target, targetDirPath);
Vector<?> entries = source.getChannel().ls(sourceDirPath);
for (Object obj : entries) {
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
String name = entry.getFilename();
if (".".equals(name) || "..".equals(name)) continue;
String childSourcePath = sourceDirPath.endsWith("/")
? sourceDirPath + name
: sourceDirPath + "/" + name;
if (entry.getAttrs().isDir()) {
transferDirectoryRemote(source, childSourcePath, target, targetDirPath,
totalBytes, transferred, progressListener);
} else {
String childTargetPath = targetDirPath + "/" + name;
transferSingleFileAccumulating(source, childSourcePath,
target, childTargetPath, totalBytes, transferred, progressListener);
}
}
}
/** Stream a single file and accumulate progress into a shared counter (used during directory transfer). */
private void transferSingleFileAccumulating(SftpSession source,
String sourcePath,
SftpSession target,
String targetFilePath,
long totalBytes,
AtomicLong transferred,
TransferProgressListener progressListener) throws Exception {
final int pipeBufferSize = 65536;
PipedOutputStream pos = new PipedOutputStream();
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
Future<?> putFuture = executorService.submit(() -> {
try {
target.getChannel().put(pis, targetFilePath, new SftpProgressMonitor() {
@Override public void init(int op, String src, String dest, long max) {}
@Override
public boolean count(long count) {
long current = transferred.addAndGet(count);
if (progressListener != null) {
progressListener.onProgress(current, totalBytes);
}
return true;
}
@Override public void end() {}
}, ChannelSftp.OVERWRITE);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try { pis.close(); } catch (Exception ignored) {}
}
});
try {
source.getChannel().get(sourcePath, pos);
} finally {
try { pos.close(); } catch (Exception ignored) {}
}
try {
putFuture.get();
} finally {
try { pis.close(); } catch (Exception ignored) {}
}
}
/** Transfer a single file with its own independent progress tracking. */
private void transferSingleFileRemote(SftpSession source,
String sourcePath,
SftpSession target,
String targetPath,
TransferProgressListener progressListener) throws Exception {
SftpATTRS attrs = source.getChannel().stat(sourcePath);
final long totalBytes = attrs.getSize();
final int pipeBufferSize = 65536;
PipedOutputStream pos = new PipedOutputStream();
@@ -263,46 +417,36 @@ public class SftpService {
target.getChannel().put(pis, targetPath, new SftpProgressMonitor() {
@Override
public void init(int op, String src, String dest, long max) {
if (progressListener != null) {
progressListener.onStart(totalBytes);
}
if (progressListener != null) progressListener.onStart(totalBytes);
}
@Override
public boolean count(long count) {
long current = transferredBytes.addAndGet(count);
if (progressListener != null) {
progressListener.onProgress(current, totalBytes);
}
if (progressListener != null) progressListener.onProgress(current, totalBytes);
return true;
}
@Override
public void end() {
if (progressListener != null) {
progressListener.onProgress(totalBytes, totalBytes);
}
if (progressListener != null) progressListener.onProgress(totalBytes, totalBytes);
}
}, ChannelSftp.OVERWRITE);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try { pis.close(); } catch (Exception ignored) {}
}
});
try {
source.getChannel().get(sourcePath, pos);
} finally {
try {
pos.close();
} catch (Exception ignored) {
}
try { pos.close(); } catch (Exception ignored) {}
}
try {
putFuture.get();
} finally {
try {
pis.close();
} catch (Exception ignored) {
}
try { pis.close(); } catch (Exception ignored) {}
}
}
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import SftpPane from './SftpPane'
import type { Connection } from '../types'
export default function SftpFileSelectorModal({
open,
connection,
onSelect,
onClose,
}: {
open: boolean
connection: Connection
onSelect: (path: string) => void
onClose: () => void
}) {
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className="flex h-[80vh] w-[80vw] max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-700 bg-[#0d1117] shadow-2xl shadow-black/60">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900 px-5 py-3.5">
<div>
<h2 className="text-sm font-semibold text-white"></h2>
<p className="mt-0.5 text-xs text-slate-400">
<span className="text-purple-400">{connection.name}</span>
 · "确认选择"
</p>
</div>
<button
className="rounded-xl p-2 text-slate-500 transition hover:bg-slate-800 hover:text-white"
onClick={onClose}
aria-label="关闭"
>
<X size={18} />
</button>
</div>
{/* SftpPane in selection mode */}
<div className="min-h-0 flex-1 overflow-hidden">
<SftpPane
connection={connection}
selectedFiles={selectedFiles}
onSelectedFilesChange={setSelectedFiles}
selectionMode
onConfirmSelection={(path) => {
onSelect(path)
onClose()
}}
/>
</div>
</div>
</div>
)
}
+28
View File
@@ -70,11 +70,16 @@ export default function SftpPane({
selectedFiles,
onSelectedFilesChange,
onRefreshSignal,
selectionMode,
onConfirmSelection,
}: {
connection: Connection
selectedFiles: string[]
onSelectedFilesChange: (files: string[]) => void
onRefreshSignal?: (refresh: () => Promise<void>) => void
/** When true, shows a bottom bar for path confirmation */
selectionMode?: boolean
onConfirmSelection?: (fullPath: string) => void
}) {
const fileInputRef = useRef<HTMLInputElement | null>(null)
const uploadMenuRef = useRef<HTMLDivElement | null>(null)
@@ -978,6 +983,29 @@ export default function SftpPane({
onClose={() => setCreateDirectoryModalOpen(false)}
onSubmit={handleCreateDir}
/>
{selectionMode ? (() => {
const singleSelected = selectedFiles.length === 1 ? selectedFiles[0] : null
const confirmPath = singleSelected
? joinPath(currentPath, singleSelected)
: currentPath
return (
<div className="shrink-0 border-t border-purple-900/50 bg-purple-950/30 px-4 py-3">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs text-slate-400">{singleSelected ? '已选择文件/文件夹' : '将选择当前目录'}</p>
<p className="mt-0.5 truncate font-mono text-xs text-purple-300">{confirmPath}</p>
</div>
<button
className="shrink-0 rounded-xl bg-purple-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-purple-500"
onClick={() => onConfirmSelection?.(confirmPath)}
>
</button>
</div>
</div>
)
})() : null}
</div>
)
}
+2 -4
View File
@@ -142,10 +142,8 @@ export default function TerminalPane({
}, [visible])
return (
<div className="flex h-full flex-col overflow-hidden bg-slate-900">
<div className="flex-1 bg-black p-2 font-mono">
<div ref={containerRef} className="h-full w-full overflow-hidden rounded-2xl border border-slate-900 bg-black" />
</div>
<div className="flex h-full flex-col overflow-hidden bg-black">
<div ref={containerRef} className="h-full w-full overflow-hidden" />
</div>
)
}
+212 -51
View File
@@ -1,6 +1,18 @@
import { useState } from 'react'
import { CheckCircle2, FileUp, ListTree, Monitor, RefreshCw, Server, Target, Upload, Zap } from 'lucide-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,
@@ -8,7 +20,7 @@ import {
subscribeUploadProgress,
uploadFile,
} from '../services/sftp'
import type { Connection, TransferTaskGroup, TransferTaskItem } from '../types'
import type { Connection, ConnectionReachabilityStatus, TransferTaskGroup, TransferTaskItem } from '../types'
function aggregate(items: TransferTaskItem[]) {
const total = items.length || 1
@@ -20,18 +32,32 @@ function aggregate(items: TransferTaskItem[]) {
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: (tasks: TransferTaskGroup[]) => 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] : [])
@@ -40,11 +66,13 @@ export default function TransferCenterModal({
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) {
onTasksChange(tasks.map((task) => (task.id === groupId ? updater(task) : task)))
// 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() {
@@ -66,7 +94,7 @@ export default function TransferCenterModal({
message: '等待上传',
})),
}
onTasksChange([group, ...tasks])
onTasksChange((prev) => [group, ...prev])
targetIds.forEach(async (targetId) => {
const response = await uploadFile(targetId, localTargetPath, file, { overwrite: true })
@@ -87,7 +115,7 @@ export default function TransferCenterModal({
...item,
progress: task.progress,
status: task.status,
message: task.error || (task.status === 'success' ? '上传完成' : '正在传输...'),
message: task.error || (task.status === 'success' ? '上传完成' : task.status === 'error' ? '上传失败' : task.status === 'cancelled' ? '已取消' : '正在传输...'),
}
: item,
)
@@ -104,10 +132,11 @@ export default function TransferCenterModal({
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: '跨主机文件同步',
title: `跨主机分发 · ${sourceName}`,
status: 'running',
progress: 0,
createdAt: new Date().toLocaleTimeString(),
@@ -119,7 +148,7 @@ export default function TransferCenterModal({
message: '等待创建任务',
})),
}
onTasksChange([group, ...tasks])
onTasksChange((prev) => [group, ...prev])
targetIds.forEach(async (targetId) => {
const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath)
@@ -140,7 +169,7 @@ export default function TransferCenterModal({
...item,
progress: task.progress,
status: task.status,
message: task.error || (task.status === 'success' ? '同步完成' : '正在同步...'),
message: task.error || (task.status === 'success' ? '同步完成' : task.status === 'error' ? '同步失败' : task.status === 'cancelled' ? '已取消' : '正在同步...'),
}
: item,
)
@@ -155,16 +184,24 @@ export default function TransferCenterModal({
}
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')}>
<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')}>
<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} />
@@ -173,7 +210,8 @@ export default function TransferCenterModal({
</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">
{/* 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">
@@ -196,10 +234,19 @@ export default function TransferCenterModal({
<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">
{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) =>
@@ -207,60 +254,143 @@ export default function TransferCenterModal({
)
}
/>
<Server size={14} className="text-slate-500" />
<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 bg-blue-600 py-3 font-medium text-white transition hover:bg-blue-500" onClick={() => void handleStartLocal()}>
<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 flex-1 gap-6">
<div className="flex w-1/2 flex-col gap-5 border-r border-slate-800 pr-6">
<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>
<label className="space-y-2">
<div className="space-y-2">
<div className="flex items-center gap-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>
{(() => {
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>
<div className="flex w-1/2 flex-col gap-5">
<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)} />
<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">
<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.map((item) => item.id))}>
<button
className="text-xs text-blue-400"
onClick={() => setTargetIds(connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.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">
<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) =>
@@ -268,20 +398,38 @@ export default function TransferCenterModal({
)
}
/>
<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 bg-purple-600 py-3 font-medium text-white transition hover:bg-purple-500" onClick={() => void handleStartRemote()}>
<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">
@@ -317,12 +465,8 @@ export default function TransferCenterModal({
</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 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">
@@ -330,7 +474,10 @@ export default function TransferCenterModal({
<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">
@@ -356,5 +503,19 @@ export default function TransferCenterModal({
</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)}
/>
)}
</>
)
}
+3 -2
View File
@@ -757,8 +757,8 @@ export default function WorkspacePage({
'rounded-md p-2 transition',
layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
)}
onClick={() => setLayout('split')}
title="分屏"
onClick={() => setLayout(layout === 'split' ? 'terminal' : 'split')}
title={layout === 'split' ? '切换到终端' : '分屏'}
>
<SplitSquareHorizontal size={15} />
</button>
@@ -857,6 +857,7 @@ export default function WorkspacePage({
<TransferCenterModal
open={showTransferModal}
connections={connections}
connectionStatuses={connectionStatuses}
tasks={transferTasks}
onTasksChange={setTransferTasks}
onClose={() => setShowTransferModal(false)}