diff --git a/src/main/java/com/sftp/manager/controller/FileController.java b/src/main/java/com/sftp/manager/controller/FileController.java index e910e34..9abb4bc 100644 --- a/src/main/java/com/sftp/manager/controller/FileController.java +++ b/src/main/java/com/sftp/manager/controller/FileController.java @@ -24,6 +24,7 @@ import java.nio.file.Files; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; @RestController @RequestMapping("/api/files") @@ -176,61 +177,201 @@ public class FileController { // 服务器间传输 @PostMapping("/transfer") public ApiResponse transferFiles(@RequestBody TransferRequest request) { + String sourceSessionId = request.getSourceSessionId(); + String targetSessionId = request.getTargetSessionId(); + String targetPath = request.getTargetPath(); + + String prefix; + if ("local".equals(sourceSessionId) && !"local".equals(targetSessionId)) { + prefix = "上传到服务器失败"; + } else if (!"local".equals(sourceSessionId) && "local".equals(targetSessionId)) { + prefix = "下载到本地失败"; + } else if (!"local".equals(sourceSessionId) && !"local".equals(targetSessionId)) { + prefix = "服务器间传输失败"; + } else { + prefix = "传输失败"; + } + try { - String sourceSessionId = request.getSourceSessionId(); - String sourcePath = request.getSourcePath(); - String targetSessionId = request.getTargetSessionId(); - String targetPath = request.getTargetPath(); - // 获取源文件名 - String fileName; - if ("local".equals(sourceSessionId)) { - File file = new File(sourcePath); - fileName = file.getName(); - } else { - FileInfo fileInfo = sftpService.getFileInfo(sourceSessionId, sourcePath); - fileName = fileInfo.getName(); + List sourcePaths = request.getSourcePaths(); + if (sourcePaths == null || sourcePaths.isEmpty()) { + // 兼容旧字段:仅当 sourcePaths 为空时才使用单个 sourcePath + if (request.getSourcePath() == null || request.getSourcePath().isEmpty()) { + return ApiResponse.error("源路径不能为空"); + } + sourcePaths = java.util.Collections.singletonList(request.getSourcePath()); } - // 构建目标路径 - String finalTargetPath = targetPath.endsWith("/") ? - targetPath + fileName : - targetPath + "/" + fileName; + boolean recursive = Boolean.TRUE.equals(request.getRecursive()); - if ("local".equals(sourceSessionId) && "local".equals(targetSessionId)) { - // 本地到本地 - Files.copy(new File(sourcePath).toPath(), new File(finalTargetPath).toPath()); - } else if ("local".equals(sourceSessionId)) { - // 本地到SFTP(上传到服务器) - localFileService.uploadToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); - } else if ("local".equals(targetSessionId)) { - // SFTP到本地(从服务器下载) - localFileService.downloadFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService); - } else { - // SFTP到SFTP - sftpService.transferBetweenSftp(sourceSessionId, sourcePath, - targetSessionId, finalTargetPath); + if (targetPath == null || targetPath.isEmpty()) { + return ApiResponse.error("目标路径不能为空"); } - return ApiResponse.success("传输成功", null); + int successCount = 0; + int failCount = 0; + StringBuilder failMessages = new StringBuilder(); + + for (String sourcePath : sourcePaths) { + if (sourcePath == null || sourcePath.isEmpty()) { + failCount++; + failMessages.append("源路径为空; "); + continue; + } + + boolean isDirectory; + String fileName; + if ("local".equals(sourceSessionId)) { + File file = new File(sourcePath); + if (!file.exists()) { + failCount++; + failMessages.append(sourcePath).append(" 不存在; "); + continue; + } + isDirectory = file.isDirectory(); + fileName = file.getName(); + } else { + FileInfo fileInfo = sftpService.getFileInfo(sourceSessionId, sourcePath); + isDirectory = fileInfo.isDirectory(); + fileName = fileInfo.getName(); + } + + // 构建目标路径(目标目录下的文件/目录名称与源名称保持一致) + String finalTargetPath = targetPath.endsWith("/") ? + targetPath + fileName : + targetPath + "/" + fileName; + + try { + if ("local".equals(sourceSessionId) && "local".equals(targetSessionId)) { + // 本地到本地 + if (isDirectory) { + if (!recursive) { + throw new Exception("目录传输需要开启递归(recursive=true)"); + } + copyLocalDirectory(sourcePath, finalTargetPath); + } else { + Files.copy(new File(sourcePath).toPath(), new File(finalTargetPath).toPath()); + } + } else if ("local".equals(sourceSessionId)) { + // 本地到SFTP(上传到服务器) + if (isDirectory) { + if (!recursive) { + throw new Exception("目录传输需要开启递归(recursive=true)"); + } + localFileService.uploadDirectoryToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); + } else { + localFileService.uploadToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); + } + } else if ("local".equals(targetSessionId)) { + // SFTP到本地(从服务器下载) + if (isDirectory) { + if (!recursive) { + throw new Exception("目录传输需要开启递归(recursive=true)"); + } + localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService); + } else { + localFileService.downloadFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService); + } + } else { + // SFTP到SFTP + if (isDirectory) { + if (!recursive) { + throw new Exception("目录传输需要开启递归(recursive=true)"); + } + // 通过本地临时目录实现 SFTP 之间的目录传输 + String tempRoot = System.getProperty("java.io.tmpdir") + + File.separator + "sftp-manager-" + UUID.randomUUID(); + File tempDir = new File(tempRoot); + try { + localFileService.downloadDirectoryFromSftp(sourceSessionId, sourcePath, tempRoot, sftpService); + localFileService.uploadDirectoryToSftp(tempRoot, targetSessionId, finalTargetPath, sftpService); + } finally { + deleteLocalDirectory(tempDir); + } + } else { + sftpService.transferBetweenSftp(sourceSessionId, sourcePath, + targetSessionId, finalTargetPath); + } + } + successCount++; + } catch (Exception e) { + failCount++; + String msg = e.getMessage() != null ? e.getMessage() : "未知错误"; + failMessages.append(sourcePath).append(" - ").append(msg).append("; "); + } + } + + if (failCount == 0) { + return ApiResponse.success("传输成功", null); + } else if (successCount == 0) { + String msg = failMessages.length() > 0 ? failMessages.toString() : "全部传输失败"; + return ApiResponse.error(prefix + ": " + msg); + } else { + String msg = "部分文件传输失败,成功 " + successCount + " 个,失败 " + failCount + " 个。详情: " + failMessages; + return ApiResponse.error(prefix + ": " + msg); + } } catch (Exception e) { String msg = e.getMessage() != null ? e.getMessage() : "未知错误"; - String src = request.getSourceSessionId(); - String tgt = request.getTargetSessionId(); - String prefix; - if ("local".equals(src) && !"local".equals(tgt)) { - prefix = "上传到服务器失败"; - } else if (!"local".equals(src) && "local".equals(tgt)) { - prefix = "下载到本地失败"; - } else if (!"local".equals(src) && !"local".equals(tgt)) { - prefix = "服务器间传输失败"; - } else { - prefix = "传输失败"; - } return ApiResponse.error(prefix + ": " + msg); } } + /** + * 递归复制本地目录 + */ + private void copyLocalDirectory(String sourceDirPath, String targetDirPath) throws Exception { + File sourceDir = new File(sourceDirPath); + if (!sourceDir.exists() || !sourceDir.isDirectory()) { + throw new Exception("本地目录不存在: " + sourceDirPath); + } + + File targetDir = new File(targetDirPath); + if (!targetDir.exists() && !targetDir.mkdirs()) { + throw new Exception("创建目标目录失败: " + targetDirPath); + } + + File[] files = sourceDir.listFiles(); + if (files == null) { + return; + } + + for (File file : files) { + File targetChild = new File(targetDir, file.getName()); + if (file.isDirectory()) { + copyLocalDirectory(file.getAbsolutePath(), targetChild.getAbsolutePath()); + } else { + Files.copy(file.toPath(), targetChild.toPath()); + } + } + } + + /** + * 递归删除本地目录(用于清理临时目录) + */ + private void deleteLocalDirectory(File directory) { + if (directory == null || !directory.exists()) { + return; + } + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteLocalDirectory(file); + } else { + try { + Files.deleteIfExists(file.toPath()); + } catch (Exception ignored) { + } + } + } + } + try { + Files.deleteIfExists(directory.toPath()); + } catch (Exception ignored) { + } + } + // 删除单个文件 @DeleteMapping("/delete") public ApiResponse deleteFile(@RequestParam String sessionId, diff --git a/src/main/java/com/sftp/manager/dto/TransferRequest.java b/src/main/java/com/sftp/manager/dto/TransferRequest.java index 08f8a6a..f48bcf3 100644 --- a/src/main/java/com/sftp/manager/dto/TransferRequest.java +++ b/src/main/java/com/sftp/manager/dto/TransferRequest.java @@ -2,10 +2,21 @@ package com.sftp.manager.dto; import lombok.Data; +import java.util.List; + @Data public class TransferRequest { private String sourceSessionId; // 源会话ID private String sourcePath; // 源文件路径 + /** + * 源路径列表(支持一次传输多个文件/目录) + * 兼容旧字段:当 sourcePaths 为空且 sourcePath 不为空时,后端会自动将 sourcePath 转为单元素列表 + */ + private List sourcePaths; private String targetSessionId; // 目标会话ID private String targetPath; // 目标路径 + /** + * 是否递归处理目录(包含子目录和文件) + */ + private Boolean recursive; } diff --git a/src/main/java/com/sftp/manager/service/LocalFileService.java b/src/main/java/com/sftp/manager/service/LocalFileService.java index 6f49d45..feea881 100644 --- a/src/main/java/com/sftp/manager/service/LocalFileService.java +++ b/src/main/java/com/sftp/manager/service/LocalFileService.java @@ -9,12 +9,14 @@ import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream; +import java.nio.file.FileVisitOption; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; @Service @@ -110,6 +112,61 @@ public class LocalFileService { } } + /** + * 递归上传本地目录到 SFTP,保持目录结构 + * + * @param localDirPath 本地目录路径 + * @param sessionId SFTP 会话 ID + * @param remoteDirPath 目标 SFTP 目录路径(对应 localDirPath 本身) + */ + public void uploadDirectoryToSftp(String localDirPath, String sessionId, + String remoteDirPath, SftpService sftpService) throws Exception { + File root = new File(localDirPath); + if (!root.exists() || !root.isDirectory()) { + throw new Exception("本地目录不存在: " + localDirPath); + } + + Path rootPath = root.toPath(); + + try { + // 先确保根目录存在 + sftpService.ensureDirectories(sessionId, remoteDirPath); + + Files.walk(rootPath, FileVisitOption.FOLLOW_LINKS).forEach(path -> { + try { + if (Files.isDirectory(path)) { + if (path.equals(rootPath)) { + // 根目录已处理 + return; + } + String relative = rootPath.relativize(path).toString().replace(File.separatorChar, '/'); + String targetDir = remoteDirPath.endsWith("/") ? + remoteDirPath + relative : + remoteDirPath + "/" + relative; + sftpService.ensureDirectories(sessionId, targetDir); + } else { + String relative = rootPath.relativize(path).toString().replace(File.separatorChar, '/'); + String targetFilePath = remoteDirPath.endsWith("/") ? + remoteDirPath + relative : + remoteDirPath + "/" + relative; + String parent = targetFilePath.contains("/") ? + targetFilePath.substring(0, targetFilePath.lastIndexOf('/')) : + "/"; + sftpService.ensureDirectories(sessionId, parent); + uploadToSftp(path.toString(), sessionId, targetFilePath, sftpService); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } catch (RuntimeException e) { + if (e.getCause() instanceof Exception) { + throw (Exception) e.getCause(); + } + throw e; + } + } + // 从SFTP下载到本地 public void downloadFromSftp(String sessionId, String remotePath, String localPath, SftpService sftpService) throws Exception { @@ -126,6 +183,47 @@ public class LocalFileService { } } + /** + * 递归从 SFTP 下载目录到本地,保持目录结构 + * + * @param sessionId SFTP 会话 ID + * @param remoteDirPath 远程目录路径 + * @param localDirPath 本地目标目录路径(对应 remoteDirPath 本身) + */ + public void downloadDirectoryFromSftp(String sessionId, String remoteDirPath, + String localDirPath, SftpService sftpService) throws Exception { + File root = new File(localDirPath); + if (!root.exists() && !root.mkdirs()) { + throw new Exception("创建本地目录失败: " + localDirPath); + } + downloadDirectoryFromSftpRecursive(sessionId, remoteDirPath, root, sftpService); + } + + private void downloadDirectoryFromSftpRecursive(String sessionId, String remoteDirPath, + File localDir, SftpService sftpService) throws Exception { + if (!localDir.exists() && !localDir.mkdirs()) { + throw new Exception("创建本地目录失败: " + localDir.getPath()); + } + + List remoteFiles = sftpService.listFiles(sessionId, remoteDirPath, true); + if (remoteFiles == null) { + return; + } + + for (FileInfo fileInfo : remoteFiles) { + boolean isDir = fileInfo.isDirectory(); + String name = fileInfo.getName(); + String childRemotePath = fileInfo.getPath(); + File childLocal = new File(localDir, name); + + if (isDir) { + downloadDirectoryFromSftpRecursive(sessionId, childRemotePath, childLocal, sftpService); + } else { + downloadFromSftp(sessionId, childRemotePath, childLocal.getAbsolutePath(), sftpService); + } + } + } + // 删除单个文件或目录 public boolean deleteFile(String path) throws Exception { File file = new File(path); diff --git a/src/main/java/com/sftp/manager/service/SftpService.java b/src/main/java/com/sftp/manager/service/SftpService.java index e17b044..fa08c81 100644 --- a/src/main/java/com/sftp/manager/service/SftpService.java +++ b/src/main/java/com/sftp/manager/service/SftpService.java @@ -304,6 +304,36 @@ public class SftpService { } } + /** + * 确保多级目录存在(若不存在则创建,已存在时不报错) + */ + public void ensureDirectories(String sessionId, String path) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + if (path == null || path.isEmpty() || "/".equals(path)) { + return; + } + + try { + channel.stat(path); + // 目录已存在,直接返回 + return; + } catch (SftpException e) { + if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) { + throw new Exception("检查目录失败: " + e.getMessage(), e); + } + } + + try { + createDirectoriesRecursive(channel, path); + } catch (SftpException e) { + throw new Exception("创建目录失败: " + e.getMessage(), e); + } + } + // 递归创建多级目录 private void createDirectoriesRecursive(ChannelSftp channel, String path) throws SftpException { if (path == null || path.equals("/") || path.isEmpty()) { diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 9e8e2f8..71354b7 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -171,7 +171,8 @@ - + diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 86ddd04..07c6b30 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -273,7 +273,12 @@ function getParentPath(path) { function updateSelectedFiles(panelId) { const selected = $(`#${panelId}-file-list .file-item.selected`); panelState[panelId].selectedFiles = selected.map(function() { - return $(this).data('path'); + return { + path: $(this).data('path'), + isDirectory: $(this).data('is-dir') === true || + $(this).data('is-dir') === 'true' || + $(this).data('is-dir') === 'True' + }; }).get(); } @@ -371,9 +376,10 @@ function handleFileDrop(data, targetPanelId) { contentType: 'application/json', data: JSON.stringify({ sourceSessionId: sourceSessionId, - sourcePath: sourcePath, + sourcePaths: [sourcePath], targetSessionId: targetSessionId, - targetPath: targetPath + targetPath: targetPath, + recursive: true }), success: function(response) { showTransferProgress(false); @@ -623,8 +629,17 @@ function getFileNameFromPath(path) { } // ========== 连接管理(模块03)========== +function ensureBootstrap() { + if (typeof bootstrap === 'undefined') { + alert('Bootstrap 未加载,请检查网络连接或暂时关闭广告拦截器后刷新页面'); + return false; + } + return true; +} + function showConnectionDialog() { loadConnectionList(); + if (!ensureBootstrap()) return; new bootstrap.Modal(document.getElementById('connectionModal')).show(); } @@ -758,6 +773,7 @@ function updatePanelStateWithConnection(sessionId, conn) { function showAddConnectionDialog() { document.getElementById('connection-form').reset(); + if (!ensureBootstrap()) return; new bootstrap.Modal(document.getElementById('addConnectionModal')).show(); } @@ -780,7 +796,9 @@ function saveConnection() { data: JSON.stringify(data), success: function(response) { if (response.success) { - bootstrap.Modal.getInstance(document.getElementById('addConnectionModal')).hide(); + if (typeof bootstrap !== 'undefined') { + bootstrap.Modal.getInstance(document.getElementById('addConnectionModal')).hide(); + } loadConnectionList(); } else { alert('保存失败: ' + (response.message || '未知错误')); @@ -824,7 +842,9 @@ function connectToServer(connId) { updateConnectionSelect('right'); updatePanelStateWithConnection(sessionId, conn); loadConnectionList(); - bootstrap.Modal.getInstance(document.getElementById('connectionModal')).hide(); + if (typeof bootstrap !== 'undefined') { + bootstrap.Modal.getInstance(document.getElementById('connectionModal')).hide(); + } updateStatus('已连接到 ' + (conn.name || conn.host)); } else { alert('连接失败: ' + (res.message || '未知错误')); @@ -1056,7 +1076,7 @@ function downloadFile(sessionId, path) { // 下载选中的文件 function downloadFiles() { const panelId = getSourcePanelId(); - const selectedFiles = panelState[panelId].selectedFiles; + const selectedFiles = panelState[panelId].selectedFiles || []; const sessionId = panelState[panelId].sessionId; if (selectedFiles.length === 0) { @@ -1064,8 +1084,14 @@ function downloadFiles() { return; } - selectedFiles.forEach(function(path) { - downloadFile(sessionId, path); + selectedFiles.forEach(function(item) { + if (!item || !item.path) return; + if (item.isDirectory) { + if (!confirm('当前选中包含文件夹,下载时将递归包含其所有子目录和文件,可能耗时较长,是否继续?')) { + return; + } + } + downloadFile(sessionId, item.path); }); } @@ -1074,7 +1100,7 @@ function doTransfer(sourcePanelId, targetPanelId) { const sourceSessionId = panelState[sourcePanelId].sessionId; const targetSessionId = panelState[targetPanelId].sessionId; const targetPath = panelState[targetPanelId].currentPath; - const selectedFiles = panelState[sourcePanelId].selectedFiles; + const selectedFiles = panelState[sourcePanelId].selectedFiles || []; if (selectedFiles.length === 0) { alert('请在' + (sourcePanelId === 'left' ? '左侧' : '右侧') + '面板选择要传输的文件'); @@ -1093,54 +1119,43 @@ function doTransfer(sourcePanelId, targetPanelId) { showTransferCountProgress(0, total, ''); updateTransferProgress(0, '传输中 (0/' + total + ')', true); // 后端无流式进度,使用动画条 - selectedFiles.forEach(function(sourcePath) { - $.ajax({ - url: API_BASE + 'api/files/transfer', - method: 'POST', - contentType: 'application/json', - data: JSON.stringify({ - sourceSessionId: sourceSessionId, - sourcePath: sourcePath, - targetSessionId: targetSessionId, - targetPath: targetPath - }), - success: function(response) { - if (response.success) { - completed++; - } else { - failed++; - alert('传输失败: ' + (response.message || '未知错误')); - } - const done = completed + failed; - showTransferCountProgress(done, total, getFileNameFromPath(sourcePath)); - updateTransferProgress(total > 0 ? Math.round((done / total) * 100) : 0, '传输中 (' + done + '/' + total + ')', false); - if (done === total) { - showTransferProgress(false); - if (failed === 0) { - updateStatus('所有文件传输成功'); - } else { - updateStatus('传输完成:成功 ' + completed + ',失败 ' + failed); - } - loadFiles(targetPanelId); - } - }, - error: function(xhr, status, error) { - failed++; - const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error; - alert('传输失败: ' + errMsg); - const done = completed + failed; - showTransferCountProgress(done, total, getFileNameFromPath(sourcePath)); - updateTransferProgress(total > 0 ? Math.round((done / total) * 100) : 0, '传输中 (' + done + '/' + total + ')', false); - if (done === total) { - showTransferProgress(false); - updateStatus('传输完成:成功 ' + completed + ',失败 ' + failed); - loadFiles(targetPanelId); - } + const sourcePaths = selectedFiles + .filter(function(item) { return item && item.path; }) + .map(function(item) { return item.path; }); + + const hasDirectory = selectedFiles.some(function(item) { return item && item.isDirectory; }); + const recursive = hasDirectory; // 若包含目录,则自动递归 + + $.ajax({ + url: API_BASE + 'api/files/transfer', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sourceSessionId: sourceSessionId, + sourcePaths: sourcePaths, + targetSessionId: targetSessionId, + targetPath: targetPath, + recursive: recursive + }), + success: function(response) { + showTransferProgress(false); + if (response.success) { + updateStatus('传输成功'); + } else { + alert('传输失败: ' + (response.message || '未知错误')); + updateStatus('传输失败'); } - }); + loadFiles(targetPanelId); + }, + error: function(xhr, status, error) { + showTransferProgress(false); + const errMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : error; + alert('传输失败: ' + errMsg); + updateStatus('传输失败'); + } }); - updateStatus('正在传输 ' + total + ' 个文件...'); + updateStatus('正在传输 ' + total + ' 个项目...'); } // 传输到右侧:左侧面板选中的文件 -> 右侧面板 diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 7a2e81f..476b02b 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -158,7 +158,8 @@ - +