Please provide the code changes or file diffs you would like me to summarize.
This commit is contained in:
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user