diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7b016a8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/Makefile b/Makefile index 569dae0..b911b85 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,28 @@ -.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 down Stop and remove services\n" - @printf " make restart Restart services\n" - @printf " make logs Follow service logs\n" - @printf " make ps Show service status\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 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" 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 diff --git a/backend/src/main/java/com/sshmanager/service/SftpService.java b/backend/src/main/java/com/sshmanager/service/SftpService.java index 62deaf7..bb635b7 100644 --- a/backend/src/main/java/com/sshmanager/service/SftpService.java +++ b/backend/src/main/java/com/sshmanager/service/SftpService.java @@ -1,5 +1,5 @@ -package com.sshmanager.service; - +package com.sshmanager.service; + import com.jcraft.jsch.ChannelSftp; import com.jcraft.jsch.JSch; import com.jcraft.jsch.Session; @@ -8,12 +8,12 @@ import com.jcraft.jsch.SftpException; import com.jcraft.jsch.SftpProgressMonitor; import com.sshmanager.entity.Connection; import org.springframework.stereotype.Service; - -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PipedInputStream; -import java.io.PipedOutputStream; -import java.nio.charset.StandardCharsets; + +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Vector; @@ -21,86 +21,86 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicLong; - -@Service -public class SftpService { - - private ExecutorService executorService = Executors.newFixedThreadPool(2); - - public void setExecutorService(ExecutorService executorService) { - this.executorService = executorService; - } - + +@Service +public class SftpService { + + private ExecutorService executorService = Executors.newCachedThreadPool(); + + public void setExecutorService(ExecutorService executorService) { + this.executorService = executorService; + } + public SftpSession connect(Connection conn, String password, String privateKey, String passphrase) - throws Exception { - JSch jsch = new JSch(); - - if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { - byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); - byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) - ? passphrase.getBytes(StandardCharsets.UTF_8) : null; - jsch.addIdentity("key", keyBytes, null, passphraseBytes); - } - - Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); - session.setConfig("StrictHostKeyChecking", "no"); - // Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE - session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1"); - if (conn.getAuthType() == Connection.AuthType.PASSWORD) { - if (password == null || password.isEmpty()) { - throw new IllegalArgumentException("Password is required for password authentication"); - } - session.setConfig("PreferredAuthentications", "password"); - session.setPassword(password); - } else { - session.setConfig("PreferredAuthentications", "publickey"); - } - session.connect(10000); - - ChannelSftp channel = (ChannelSftp) session.openChannel("sftp"); - channel.connect(5000); - - return new SftpSession(session, channel); - } - - public static class SftpSession { - private final Session session; - private final ChannelSftp channel; - - public SftpSession(Session session, ChannelSftp channel) { - this.session = session; - this.channel = channel; - } - - public ChannelSftp getChannel() { - return channel; - } - - public void disconnect() { - if (channel != null && channel.isConnected()) { - channel.disconnect(); - } - if (session != null && session.isConnected()) { - session.disconnect(); - } - } - - public boolean isConnected() { - return channel != null && channel.isConnected(); - } - } - + throws Exception { + JSch jsch = new JSch(); + + if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { + byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); + byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) + ? passphrase.getBytes(StandardCharsets.UTF_8) : null; + jsch.addIdentity("key", keyBytes, null, passphraseBytes); + } + + Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); + session.setConfig("StrictHostKeyChecking", "no"); + // Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE + session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1"); + if (conn.getAuthType() == Connection.AuthType.PASSWORD) { + if (password == null || password.isEmpty()) { + throw new IllegalArgumentException("Password is required for password authentication"); + } + session.setConfig("PreferredAuthentications", "password"); + session.setPassword(password); + } else { + session.setConfig("PreferredAuthentications", "publickey"); + } + session.connect(10000); + + ChannelSftp channel = (ChannelSftp) session.openChannel("sftp"); + channel.connect(5000); + + return new SftpSession(session, channel); + } + + public static class SftpSession { + private final Session session; + private final ChannelSftp channel; + + public SftpSession(Session session, ChannelSftp channel) { + this.session = session; + this.channel = channel; + } + + public ChannelSftp getChannel() { + return channel; + } + + public void disconnect() { + if (channel != null && channel.isConnected()) { + channel.disconnect(); + } + if (session != null && session.isConnected()) { + session.disconnect(); + } + } + + public boolean isConnected() { + return channel != null && channel.isConnected(); + } + } + public static class FileInfo { public String name; public boolean directory; public long size; public long mtime; - - public FileInfo(String name, boolean directory, long size, long mtime) { - this.name = name; - this.directory = directory; - this.size = size; - this.mtime = mtime; + + public FileInfo(String name, boolean directory, long size, long mtime) { + this.name = name; + this.directory = directory; + this.size = size; + this.mtime = mtime; } } @@ -117,125 +117,126 @@ public class SftpService { void onProgress(long transferredBytes, long totalBytes); } - + public List listFiles(SftpSession sftpSession, String path) throws Exception { String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim(); try { Vector entries = sftpSession.getChannel().ls(listPath); - List result = new ArrayList<>(); - for (Object obj : entries) { - ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj; - String name = entry.getFilename(); - if (".".equals(name) || "..".equals(name)) continue; - result.add(new FileInfo( - name, - entry.getAttrs().isDir(), - entry.getAttrs().getSize(), - entry.getAttrs().getMTime() * 1000L - )); - } - return result; - } catch (SftpException e) { - String msg = formatSftpExceptionMessage(e, listPath, "list"); - throw new RuntimeException(msg, e); - } - } - - /** - * Build a user-visible message from JSch SftpException (getMessage() is often null). - */ - public static String formatSftpExceptionMessage(SftpException e, String path, String operation) { - int id = e.id; - String serverMsg = e.getMessage(); - String reason = sftpErrorCodeToMessage(id); - StringBuilder sb = new StringBuilder(); - sb.append(reason); - if (path != null && !path.isEmpty()) { - sb.append(": ").append(path); - } - if (serverMsg != null && !serverMsg.trim().isEmpty()) { - sb.append(" (").append(serverMsg).append(")"); - } else { - sb.append(" [SFTP status ").append(id).append("]"); - } - return sb.toString(); - } - - private static String sftpErrorCodeToMessage(int id) { - switch (id) { - case 2: return "No such file or directory"; - case 3: return "Permission denied"; - case 4: return "Operation failed"; - case 5: return "Bad message"; - case 6: return "No connection"; - case 7: return "Connection lost"; - case 8: return "Operation not supported"; - default: return "SFTP error"; - } - } - + List result = new ArrayList<>(); + for (Object obj : entries) { + ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj; + String name = entry.getFilename(); + if (".".equals(name) || "..".equals(name)) continue; + result.add(new FileInfo( + name, + entry.getAttrs().isDir(), + entry.getAttrs().getSize(), + entry.getAttrs().getMTime() * 1000L + )); + } + return result; + } catch (SftpException e) { + String msg = formatSftpExceptionMessage(e, listPath, "list"); + throw new RuntimeException(msg, e); + } + } + + /** + * Build a user-visible message from JSch SftpException (getMessage() is often null). + */ + public static String formatSftpExceptionMessage(SftpException e, String path, String operation) { + int id = e.id; + String serverMsg = e.getMessage(); + String reason = sftpErrorCodeToMessage(id); + StringBuilder sb = new StringBuilder(); + sb.append(reason); + if (path != null && !path.isEmpty()) { + sb.append(": ").append(path); + } + if (serverMsg != null && !serverMsg.trim().isEmpty()) { + sb.append(" (").append(serverMsg).append(")"); + } else { + sb.append(" [SFTP status ").append(id).append("]"); + } + return sb.toString(); + } + + private static String sftpErrorCodeToMessage(int id) { + switch (id) { + case 2: return "No such file or directory"; + case 3: return "Permission denied"; + case 4: return "Operation failed"; + case 5: return "Bad message"; + case 6: return "No connection"; + case 7: return "Connection lost"; + case 8: return "Operation not supported"; + default: return "SFTP error"; + } + } + public void download(SftpSession sftpSession, String remotePath, OutputStream out) throws Exception { sftpSession.getChannel().get(remotePath, out); } - public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception { - sftpSession.getChannel().put(in, remotePath); - } - - public void upload(SftpSession sftpSession, String remotePath, InputStream in, TransferProgressListener progressListener) throws Exception { - sftpSession.getChannel().put(in, remotePath, new SftpProgressMonitor() { - @Override - public void init(int op, String src, String dest, long max) { - if (progressListener != null) { - progressListener.onStart(max); - } - } - - @Override - public boolean count(long count) { - if (progressListener != null) { - progressListener.onProgress(count, 0); - } - return true; - } - - @Override - public void end() { - // Progress listener will be notified by controller - } - }, ChannelSftp.OVERWRITE); + public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception { + sftpSession.getChannel().put(in, remotePath); } - - public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception { - if (isDir) { - sftpSession.getChannel().rmdir(remotePath); - } else { - sftpSession.getChannel().rm(remotePath); - } - } - - public void mkdir(SftpSession sftpSession, String remotePath) throws Exception { - sftpSession.getChannel().mkdir(remotePath); - } - - public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception { - sftpSession.getChannel().rename(oldPath, newPath); - } - - public String pwd(SftpSession sftpSession) throws Exception { - return sftpSession.getChannel().pwd(); - } - - public void cd(SftpSession sftpSession, String path) throws Exception { - sftpSession.getChannel().cd(path); - } - - /** - * Transfer a single file from source session to target session (streaming, no full file in memory). - * Fails if sourcePath is a directory. - */ + + public void upload(SftpSession sftpSession, String remotePath, InputStream in, TransferProgressListener progressListener) throws Exception { + sftpSession.getChannel().put(in, remotePath, new SftpProgressMonitor() { + @Override + public void init(int op, String src, String dest, long max) { + if (progressListener != null) { + progressListener.onStart(max); + } + } + + @Override + public boolean count(long count) { + if (progressListener != null) { + progressListener.onProgress(count, 0); + } + return true; + } + + @Override + public void end() { + // Progress listener will be notified by controller + } + }, ChannelSftp.OVERWRITE); + } + + public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception { + if (isDir) { + sftpSession.getChannel().rmdir(remotePath); + } else { + sftpSession.getChannel().rm(remotePath); + } + } + + public void mkdir(SftpSession sftpSession, String remotePath) throws Exception { + sftpSession.getChannel().mkdir(remotePath); + } + + public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception { + sftpSession.getChannel().rename(oldPath, newPath); + } + + public String pwd(SftpSession sftpSession) throws Exception { + return sftpSession.getChannel().pwd(); + } + + public void cd(SftpSession sftpSession, String path) throws Exception { + sftpSession.getChannel().cd(path); + } + + /** + * 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 { + throws Exception { transferRemote(source, sourcePath, target, targetPath, null); } @@ -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) {} } } diff --git a/frontend/src/components/SftpFileSelectorModal.tsx b/frontend/src/components/SftpFileSelectorModal.tsx new file mode 100644 index 0000000..378e596 --- /dev/null +++ b/frontend/src/components/SftpFileSelectorModal.tsx @@ -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([]) + + if (!open) return null + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
+ {/* Header */} +
+
+

浏览远程文件

+

+ 来源:{connection.name} +  · 单击选中,双击进入目录,点击底部"确认选择"完成 +

+
+ +
+ + {/* SftpPane in selection mode */} +
+ { + onSelect(path) + onClose() + }} + /> +
+
+
+ ) +} diff --git a/frontend/src/components/SftpPane.tsx b/frontend/src/components/SftpPane.tsx index d8ee3dd..c31a6e5 100644 --- a/frontend/src/components/SftpPane.tsx +++ b/frontend/src/components/SftpPane.tsx @@ -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 + /** When true, shows a bottom bar for path confirmation */ + selectionMode?: boolean + onConfirmSelection?: (fullPath: string) => void }) { const fileInputRef = useRef(null) const uploadMenuRef = useRef(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 ( +
+
+
+

{singleSelected ? '已选择文件/文件夹' : '将选择当前目录'}

+

{confirmPath}

+
+ +
+
+ ) + })() : null} ) } diff --git a/frontend/src/components/TerminalPane.tsx b/frontend/src/components/TerminalPane.tsx index 28b7b94..8f3b7c3 100644 --- a/frontend/src/components/TerminalPane.tsx +++ b/frontend/src/components/TerminalPane.tsx @@ -142,10 +142,8 @@ export default function TerminalPane({ }, [visible]) return ( -
-
-
-
+
+
) } diff --git a/frontend/src/components/TransferCenterModal.tsx b/frontend/src/components/TransferCenterModal.tsx index 284b099..3484d04 100644 --- a/frontend/src/components/TransferCenterModal.tsx +++ b/frontend/src/components/TransferCenterModal.tsx @@ -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 + if (status === 'offline') + return + if (status === 'checking') + return + return +} + + + export default function TransferCenterModal({ open, connections, + connectionStatuses, tasks, onClose, onTasksChange, }: { open: boolean connections: Connection[] + connectionStatuses: Record 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(connections.length ? [connections[0].id] : []) @@ -40,11 +66,13 @@ export default function TransferCenterModal({ const [remoteSourceId, setRemoteSourceId] = useState(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 ( - + <> +
+ {/* Tabs */}
- -
-
+ {/* Left panel */} +
{tab === 'local' ? (
- ) : ( -
-
-

- - 源配置 -

- -