From 80fc5c8a0fb25a7019a7d937895f3552c6cce5e6 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Thu, 12 Mar 2026 17:45:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Transfers=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=96=87=E4=BB=B6=E6=B5=8F=E8=A7=88=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 SftpFilePickerModal 中添加搜索功能 - 添加显示/隐藏文件切换按钮(参考 SftpView) - Remote->Many 模式下目标连接列表自动排除源连接 - 全选功能自动排除源连接 - 添加空状态提示信息 - 优化用户体验和交互逻辑 --- AGENTS.md | 17 +- README.md | 32 +- .../config/SftpSessionCleanupTask.java | 4 + .../sshmanager/controller/SftpController.java | 654 ++++++++++++++++-- .../security/JwtAuthenticationFilter.java | 5 +- .../com/sshmanager/service/SftpService.java | 163 +++-- design-system/MASTER.md | 38 - docs/design-system/MASTER.md | 43 ++ frontend/index.html | 8 +- frontend/src/api/sftp.ts | 167 ++++- .../src/components/SftpFilePickerModal.vue | 222 ++++++ frontend/src/layouts/MainLayout.vue | 42 +- frontend/src/router/index.ts | 37 +- frontend/src/stores/transfers.ts | 389 +++++++++++ frontend/src/style.css | 22 +- frontend/src/views/LoginView.vue | 8 +- frontend/src/views/SftpView.vue | 225 ++++-- frontend/src/views/TransfersView.vue | 516 ++++++++++++++ 18 files changed, 2298 insertions(+), 294 deletions(-) delete mode 100644 design-system/MASTER.md create mode 100644 docs/design-system/MASTER.md create mode 100644 frontend/src/components/SftpFilePickerModal.vue create mode 100644 frontend/src/stores/transfers.ts create mode 100644 frontend/src/views/TransfersView.vue diff --git a/AGENTS.md b/AGENTS.md index 4cc0fad..8d572f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,17 +150,26 @@ - 检查未提交敏感信息与本地配置 - 仅提交与需求直接相关的文件 -## 9) 文档与规则文件检查结果 +## 9) Makefile 快捷命令(仓库根目录) -- `AGENTS.md`:本文件为新建(仓库根目录) +- `make build`:构建 Docker 镜像 +- `make up`:构建并后台启动服务 +- `make down`:停止并移除服务 +- `make restart`:重启服务 +- `make logs`:查看服务日志 +- `make ps`:查看服务状态 + +## 10) 文档与规则文件检查结果 + +- `AGENTS.md`:本文件(仓库根目录) - Cursor 规则:未发现 `.cursor/rules/` 或 `.cursorrules` - Copilot 规则:未发现 `.github/copilot-instructions.md` 若未来新增上述规则文件,agents 必须先读取并将其视为高优先级约束。 -## 10) 近期修复记录(2026-03-11) +## 11) 近期修复记录 -### 10.1 Docker 启动失败修复 +### 11.1 Docker 启动失败修复 **问题现象** ```text diff --git a/README.md b/README.md index ff72c0f..0744de7 100644 --- a/README.md +++ b/README.md @@ -53,22 +53,22 @@ cd frontend && npm run build ## 项目结构 ``` -ssh-manager/ -├── backend/ # Spring Boot(JDK 8) -│ └── src/main/java/com/sshmanager/ -│ ├── config/ # 安全、WebSocket、CORS -│ ├── controller/ -│ ├── service/ -│ ├── entity/ -│ └── repository/ -├── frontend/ # Vue 3 + Vite + Tailwind -│ └── src/ -│ ├── views/ -│ ├── components/ -│ ├── stores/ -│ └── api/ -└── design-system/ # UI/UX 规范 -``` +ssh-manager/ +├── backend/ # Spring Boot(JDK 8) +│ └── src/main/java/com/sshmanager/ +│ ├── config/ # 安全、WebSocket、CORS +│ ├── controller/ +│ ├── service/ +│ ├── entity/ +│ └── repository/ +├── frontend/ # Vue 3 + Vite + Tailwind +│ └── src/ +│ ├── views/ +│ ├── components/ +│ ├── stores/ +│ └── api/ +└── docs/design-system/ # UI/UX 规范 +``` ## 配置 diff --git a/backend/src/main/java/com/sshmanager/config/SftpSessionCleanupTask.java b/backend/src/main/java/com/sshmanager/config/SftpSessionCleanupTask.java index dcb19c3..fde35d4 100644 --- a/backend/src/main/java/com/sshmanager/config/SftpSessionCleanupTask.java +++ b/backend/src/main/java/com/sshmanager/config/SftpSessionCleanupTask.java @@ -15,6 +15,9 @@ public class SftpSessionCleanupTask { @Value("${sshmanager.sftp-session-timeout-minutes:30}") private int sessionTimeoutMinutes; + @Value("${sshmanager.transfer-task-timeout-minutes:30}") + private int transferTaskTimeoutMinutes; + private final SftpController sftpController; public SftpSessionCleanupTask(SftpController sftpController) { @@ -25,5 +28,6 @@ public class SftpSessionCleanupTask { public void cleanupIdleSessions() { log.debug("Running SFTP session cleanup task"); sftpController.cleanupExpiredSessions(sessionTimeoutMinutes); + sftpController.cleanupExpiredTransferTasks(transferTaskTimeoutMinutes); } } diff --git a/backend/src/main/java/com/sshmanager/controller/SftpController.java b/backend/src/main/java/com/sshmanager/controller/SftpController.java index 565638b..4f6e80f 100644 --- a/backend/src/main/java/com/sshmanager/controller/SftpController.java +++ b/backend/src/main/java/com/sshmanager/controller/SftpController.java @@ -17,11 +17,19 @@ import org.springframework.stereotype.Component; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -38,6 +46,10 @@ public class SftpController { private final Map sessions = new ConcurrentHashMap<>(); private final Map sessionLocks = new ConcurrentHashMap<>(); + private final Map transferTasks = new ConcurrentHashMap<>(); + private final Map uploadTasks = new ConcurrentHashMap<>(); + private final ExecutorService transferTaskExecutor = Executors.newCachedThreadPool(); + private final Map> taskEmitters = new ConcurrentHashMap<>(); public SftpController(ConnectionService connectionService, UserRepository userRepository, @@ -56,6 +68,14 @@ public class SftpController { return userId + ":" + connectionId; } + private String transferTaskKey(Long userId, String taskId) { + return userId + ":" + taskId; + } + + private String uploadTaskKey(Long userId, String taskId) { + return userId + ":" + taskId; + } + private T withSessionLock(String key, Supplier action) { Object lock = sessionLocks.computeIfAbsent(key, k -> new Object()); synchronized (lock) { @@ -143,6 +163,64 @@ public class SftpController { return operation + " failed"; } + private ResponseEntity> validateTransferPaths(String sourcePath, String targetPath) { + if (sourcePath == null || sourcePath.trim().isEmpty()) { + Map err = new HashMap<>(); + err.put("error", "sourcePath is required"); + return ResponseEntity.badRequest().body(err); + } + if (targetPath == null || targetPath.trim().isEmpty()) { + Map err = new HashMap<>(); + err.put("error", "targetPath is required"); + return ResponseEntity.badRequest().body(err); + } + return null; + } + + private void executeTransfer(Long userId, + Long sourceConnectionId, + String sourcePath, + Long targetConnectionId, + String targetPath, + TransferTaskStatus status) throws Exception { + String cleanSourcePath = sourcePath.trim(); + String cleanTargetPath = targetPath.trim(); + String sourceKey = sessionKey(userId, sourceConnectionId); + String targetKey = sessionKey(userId, targetConnectionId); + + withTwoSessionLocks(sourceKey, targetKey, () -> { + try { + SftpService.SftpSession sourceSession = getOrCreateSession(sourceConnectionId, userId); + SftpService.SftpSession targetSession = getOrCreateSession(targetConnectionId, userId); + sftpService.transferRemote(sourceSession, cleanSourcePath, targetSession, cleanTargetPath, + new SftpService.TransferProgressListener() { + @Override + public void onStart(long totalBytes) { + status.setProgress(0, totalBytes); + } + + @Override + public void onProgress(long transferredBytes, long totalBytes) { + status.setProgress(transferredBytes, totalBytes); + } + }); + return null; + } catch (Exception e) { + SftpService.SftpSession source = sessions.remove(sourceKey); + if (source != null) { + source.disconnect(); + } + if (!sourceKey.equals(targetKey)) { + SftpService.SftpSession target = sessions.remove(targetKey); + if (target != null) { + target.disconnect(); + } + } + throw new RuntimeException(e); + } + }); + } + @GetMapping("/pwd") public ResponseEntity> pwd( @RequestParam Long connectionId, @@ -207,36 +285,69 @@ public class SftpController { } @PostMapping("/upload") - public ResponseEntity> upload( + public ResponseEntity> upload( @RequestParam Long connectionId, @RequestParam String path, @RequestParam("file") MultipartFile file, Authentication authentication) { try { Long userId = getCurrentUserId(authentication); - String key = sessionKey(userId, connectionId); - return withSessionLock(key, () -> { + String taskId = UUID.randomUUID().toString(); + String taskKey = uploadTaskKey(userId, taskId); + + UploadTaskStatus status = new UploadTaskStatus(taskId, userId, connectionId, + path, file.getOriginalFilename(), file.getSize()); + status.setController(this); + uploadTasks.put(taskKey, status); + + Future future = transferTaskExecutor.submit(() -> { + status.setStatus("running"); + String key = sessionKey(userId, connectionId); try { - SftpService.SftpSession session = getOrCreateSession(connectionId, userId); - String remotePath = (path == null || path.isEmpty() || path.equals("/")) - ? "/" + file.getOriginalFilename() - : (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename()); - try (java.io.InputStream in = file.getInputStream()) { - sftpService.upload(session, remotePath, in); - } - Map result = new HashMap<>(); - result.put("message", "Uploaded"); - return ResponseEntity.ok(result); + withSessionLock(key, () -> { + try { + SftpService.SftpSession session = getOrCreateSession(connectionId, userId); + String remotePath = (path == null || path.isEmpty() || path.equals("/")) + ? "/" + file.getOriginalFilename() + : (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename()); + + AtomicLong transferred = new AtomicLong(0); + try (java.io.InputStream in = file.getInputStream()) { + sftpService.upload(session, remotePath, in, new SftpService.TransferProgressListener() { + @Override + public void onStart(long totalBytes) { + status.setProgress(0, totalBytes); + } + + @Override + public void onProgress(long count, long totalBytes) { + long current = transferred.addAndGet(count); + status.setProgress(current, status.getTotalBytes()); + } + }); + } + status.markSuccess(); + return null; + } catch (Exception e) { + SftpService.SftpSession existing = sessions.remove(key); + if (existing != null) { + existing.disconnect(); + } + throw new RuntimeException(e); + } + }); } catch (Exception e) { - SftpService.SftpSession existing = sessions.remove(key); - if (existing != null) { - existing.disconnect(); - } - throw new RuntimeException(e); + status.markError(e.getMessage() != null ? e.getMessage() : "Upload failed"); } }); + status.setFuture(future); + + Map result = new HashMap<>(); + result.put("taskId", taskId); + result.put("message", "Upload started"); + return ResponseEntity.ok(result); } catch (Exception e) { - Map error = new HashMap<>(); + Map error = new HashMap<>(); error.put("error", e.getMessage()); return ResponseEntity.status(500).body(error); } @@ -343,42 +454,13 @@ public class SftpController { Authentication authentication) { try { Long userId = getCurrentUserId(authentication); - if (sourcePath == null || sourcePath.trim().isEmpty()) { - Map err = new HashMap<>(); - err.put("error", "sourcePath is required"); - return ResponseEntity.badRequest().body(err); + ResponseEntity> validation = validateTransferPaths(sourcePath, targetPath); + if (validation != null) { + return validation; } - if (targetPath == null || targetPath.trim().isEmpty()) { - Map err = new HashMap<>(); - err.put("error", "targetPath is required"); - return ResponseEntity.badRequest().body(err); - } - String sourceKey = sessionKey(userId, sourceConnectionId); - String targetKey = sessionKey(userId, targetConnectionId); - withTwoSessionLocks(sourceKey, targetKey, () -> { - try { - SftpService.SftpSession sourceSession = getOrCreateSession(sourceConnectionId, userId); - SftpService.SftpSession targetSession = getOrCreateSession(targetConnectionId, userId); - if (sourceConnectionId.equals(targetConnectionId)) { - sftpService.rename(sourceSession, sourcePath.trim(), targetPath.trim()); - } else { - sftpService.transferRemote(sourceSession, sourcePath.trim(), targetSession, targetPath.trim()); - } - return null; - } catch (Exception e) { - SftpService.SftpSession source = sessions.remove(sourceKey); - if (source != null) { - source.disconnect(); - } - if (!sourceKey.equals(targetKey)) { - SftpService.SftpSession target = sessions.remove(targetKey); - if (target != null) { - target.disconnect(); - } - } - throw new RuntimeException(e); - } - }); + TransferTaskStatus status = new TransferTaskStatus(UUID.randomUUID().toString(), userId, sourceConnectionId, targetConnectionId, + sourcePath.trim(), targetPath.trim()); + executeTransfer(userId, sourceConnectionId, sourcePath, targetConnectionId, targetPath, status); Map result = new HashMap<>(); result.put("message", "Transferred"); return ResponseEntity.ok(result); @@ -389,6 +471,206 @@ public class SftpController { } } + @PostMapping("/transfer-remote/tasks") + public ResponseEntity> createTransferRemoteTask( + @RequestParam Long sourceConnectionId, + @RequestParam String sourcePath, + @RequestParam Long targetConnectionId, + @RequestParam String targetPath, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + ResponseEntity> validation = validateTransferPaths(sourcePath, targetPath); + if (validation != null) { + Map err = new HashMap<>(); + err.putAll(validation.getBody()); + return ResponseEntity.status(validation.getStatusCode()).body(err); + } + + TransferTaskStatus status = new TransferTaskStatus(UUID.randomUUID().toString(), userId, sourceConnectionId, targetConnectionId, + sourcePath.trim(), targetPath.trim()); + status.setController(this); + String taskKey = transferTaskKey(userId, status.getTaskId()); + transferTasks.put(taskKey, status); + + Future future = transferTaskExecutor.submit(() -> { + status.setStatus("running"); + try { + if (Thread.currentThread().isInterrupted()) { + status.markCancelled(); + return; + } + executeTransfer(userId, sourceConnectionId, sourcePath, targetConnectionId, targetPath, status); + status.markSuccess(); + } catch (Exception e) { + if (e instanceof InterruptedException || Thread.currentThread().isInterrupted()) { + status.markCancelled(); + return; + } + status.markError(toSftpErrorMessage(e, sourcePath, "transfer")); + log.warn("SFTP transfer task failed: taskId={}, sourceConnectionId={}, sourcePath={}, targetConnectionId={}, targetPath={}, error={}", + status.getTaskId(), sourceConnectionId, sourcePath, targetConnectionId, targetPath, e.getMessage(), e); + } + }); + status.setFuture(future); + + return ResponseEntity.ok(status.toResponse()); + } + + @GetMapping("/transfer-remote/tasks/{taskId}") + public ResponseEntity> getTransferRemoteTask( + @PathVariable String taskId, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + TransferTaskStatus status = transferTasks.get(transferTaskKey(userId, taskId)); + if (status == null) { + Map error = new HashMap<>(); + error.put("error", "Transfer task not found"); + return ResponseEntity.status(404).body(error); + } + return ResponseEntity.ok(status.toResponse()); + } + + @GetMapping("/upload/tasks/{taskId}") + public ResponseEntity> getUploadTask( + @PathVariable String taskId, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + UploadTaskStatus status = uploadTasks.get(uploadTaskKey(userId, taskId)); + if (status == null) { + Map error = new HashMap<>(); + error.put("error", "Upload task not found"); + return ResponseEntity.status(404).body(error); + } + return ResponseEntity.ok(status.toResponse()); + } + + @DeleteMapping("/transfer-remote/tasks/{taskId}") + public ResponseEntity> cancelTransferRemoteTask( + @PathVariable String taskId, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + TransferTaskStatus status = transferTasks.get(transferTaskKey(userId, taskId)); + if (status == null) { + Map error = new HashMap<>(); + error.put("error", "Transfer task not found"); + return ResponseEntity.status(404).body(error); + } + + boolean cancelled = status.cancel(); + Map result = status.toResponse(); + result.put("cancelRequested", cancelled); + if (!cancelled) { + result.put("message", "Task already running or finished; current transfer cannot be interrupted safely"); + } + return ResponseEntity.ok(result); + } + + @GetMapping("/transfer-remote/tasks/{taskId}/progress") + public SseEmitter streamTransferProgress( + @PathVariable String taskId, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + String taskKey = transferTaskKey(userId, taskId); + TransferTaskStatus status = transferTasks.get(taskKey); + + SseEmitter emitter = new SseEmitter(300000L); // 5 minutes timeout + + if (status == null) { + try { + Map error = new HashMap<>(); + error.put("error", "Task not found"); + emitter.send(SseEmitter.event().name("error").data(error)); + emitter.complete(); + } catch (IOException e) { + emitter.completeWithError(e); + } + return emitter; + } + + taskEmitters.computeIfAbsent(taskKey, k -> new CopyOnWriteArrayList<>()).add(emitter); + + emitter.onCompletion(() -> removeEmitter(taskKey, emitter)); + emitter.onTimeout(() -> removeEmitter(taskKey, emitter)); + emitter.onError((e) -> removeEmitter(taskKey, emitter)); + + // Send initial status + try { + emitter.send(SseEmitter.event().name("progress").data(status.toResponse())); + } catch (IOException e) { + removeEmitter(taskKey, emitter); + } + + return emitter; + } + + @GetMapping("/upload/tasks/{taskId}/progress") + public SseEmitter streamUploadProgress( + @PathVariable String taskId, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + String taskKey = uploadTaskKey(userId, taskId); + UploadTaskStatus status = uploadTasks.get(taskKey); + + SseEmitter emitter = new SseEmitter(300000L); // 5 minutes timeout + + if (status == null) { + try { + Map error = new HashMap<>(); + error.put("error", "Task not found"); + emitter.send(SseEmitter.event().name("error").data(error)); + emitter.complete(); + } catch (IOException e) { + emitter.completeWithError(e); + } + return emitter; + } + + taskEmitters.computeIfAbsent(taskKey, k -> new CopyOnWriteArrayList<>()).add(emitter); + + emitter.onCompletion(() -> removeEmitter(taskKey, emitter)); + emitter.onTimeout(() -> removeEmitter(taskKey, emitter)); + emitter.onError((e) -> removeEmitter(taskKey, emitter)); + + // Send initial status + try { + emitter.send(SseEmitter.event().name("progress").data(status.toResponse())); + } catch (IOException e) { + removeEmitter(taskKey, emitter); + } + + return emitter; + } + + private void removeEmitter(String taskKey, SseEmitter emitter) { + CopyOnWriteArrayList emitters = taskEmitters.get(taskKey); + if (emitters != null) { + emitters.remove(emitter); + if (emitters.isEmpty()) { + taskEmitters.remove(taskKey); + } + } + } + + private void broadcastProgress(String taskKey, Map data) { + CopyOnWriteArrayList emitters = taskEmitters.get(taskKey); + if (emitters == null || emitters.isEmpty()) { + return; + } + + List deadEmitters = new java.util.ArrayList<>(); + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event().name("progress").data(data)); + } catch (Exception e) { + deadEmitters.add(emitter); + } + } + + for (SseEmitter dead : deadEmitters) { + removeEmitter(taskKey, dead); + } + } + @PostMapping("/disconnect") public ResponseEntity> disconnect( @RequestParam Long connectionId, @@ -419,6 +701,12 @@ public class SftpController { } } + public void cleanupExpiredTransferTasks(int timeoutMinutes) { + long now = System.currentTimeMillis(); + long timeoutMillis = timeoutMinutes * 60L * 1000L; + transferTasks.entrySet().removeIf(entry -> entry.getValue().isExpired(now, timeoutMillis)); + } + private final SftpSessionExpiryCleanup cleanupTask = new SftpSessionExpiryCleanup(); public static class SftpSessionExpiryCleanup { @@ -441,4 +729,262 @@ public class SftpController { .collect(Collectors.toList()); } } + + public static class TransferTaskStatus { + private final String taskId; + private final Long userId; + private final Long sourceConnectionId; + private final Long targetConnectionId; + private final String sourcePath; + private final String targetPath; + private final long createdAt; + private volatile String status; + private volatile String error; + private volatile long startedAt; + private volatile long finishedAt; + private final AtomicLong totalBytes; + private final AtomicLong transferredBytes; + private volatile Future future; + private volatile SftpController controller; + + public TransferTaskStatus(String taskId, + Long userId, + Long sourceConnectionId, + Long targetConnectionId, + String sourcePath, + String targetPath) { + this.taskId = taskId; + this.userId = userId; + this.sourceConnectionId = sourceConnectionId; + this.targetConnectionId = targetConnectionId; + this.sourcePath = sourcePath; + this.targetPath = targetPath; + this.createdAt = System.currentTimeMillis(); + this.status = "queued"; + this.totalBytes = new AtomicLong(0); + this.transferredBytes = new AtomicLong(0); + } + + public String getTaskId() { + return taskId; + } + + public void setController(SftpController controller) { + this.controller = controller; + } + + public void setFuture(Future future) { + this.future = future; + } + + public void setStatus(String status) { + this.status = status; + if ("running".equals(status) && startedAt == 0) { + startedAt = System.currentTimeMillis(); + } + notifyProgress(); + } + + public void setProgress(long transferred, long total) { + if (startedAt == 0) { + startedAt = System.currentTimeMillis(); + } + transferredBytes.set(Math.max(0, transferred)); + totalBytes.set(Math.max(0, total)); + notifyProgress(); + } + + public void markSuccess() { + long total = totalBytes.get(); + if (total > 0) { + transferredBytes.set(total); + } + status = "success"; + finishedAt = System.currentTimeMillis(); + notifyProgress(); + } + + public void markError(String message) { + status = "error"; + error = message; + finishedAt = System.currentTimeMillis(); + notifyProgress(); + } + + public void markCancelled() { + status = "cancelled"; + finishedAt = System.currentTimeMillis(); + notifyProgress(); + } + + private void notifyProgress() { + if (controller != null) { + String taskKey = controller.transferTaskKey(userId, taskId); + controller.broadcastProgress(taskKey, toResponse()); + } + } + + public boolean cancel() { + if (!"queued".equals(status) && !"running".equals(status)) { + return false; + } + Future currentFuture = future; + if (currentFuture != null) { + currentFuture.cancel(true); + } + markCancelled(); + return true; + } + + public Map toResponse() { + long total = totalBytes.get(); + long transferred = transferredBytes.get(); + int progress = total > 0 ? (int) Math.min(100, Math.round((transferred * 100.0) / total)) : + (("success".equals(status) || "error".equals(status) || "cancelled".equals(status)) ? 100 : 0); + + Map result = new HashMap<>(); + result.put("taskId", taskId); + result.put("userId", userId); + result.put("sourceConnectionId", sourceConnectionId); + result.put("targetConnectionId", targetConnectionId); + result.put("sourcePath", sourcePath); + result.put("targetPath", targetPath); + result.put("status", status); + result.put("progress", progress); + result.put("transferredBytes", transferred); + result.put("totalBytes", total); + result.put("createdAt", createdAt); + result.put("startedAt", startedAt); + result.put("finishedAt", finishedAt); + if (error != null && !error.isEmpty()) { + result.put("error", error); + } + return result; + } + + public boolean isExpired(long now, long timeoutMillis) { + if ("queued".equals(status) || "running".equals(status)) { + return false; + } + long endTime = finishedAt > 0 ? finishedAt : createdAt; + return now - endTime > timeoutMillis; + } + } + + public static class UploadTaskStatus { + private final String taskId; + private final Long userId; + private final Long connectionId; + private final String path; + private final String filename; + private final long fileSize; + private final long createdAt; + private volatile String status; + private volatile String error; + private volatile long startedAt; + private volatile long finishedAt; + private final AtomicLong totalBytes; + private final AtomicLong transferredBytes; + private volatile Future future; + private volatile SftpController controller; + + public UploadTaskStatus(String taskId, Long userId, Long connectionId, + String path, String filename, long fileSize) { + this.taskId = taskId; + this.userId = userId; + this.connectionId = connectionId; + this.path = path; + this.filename = filename; + this.fileSize = fileSize; + this.createdAt = System.currentTimeMillis(); + this.status = "queued"; + this.totalBytes = new AtomicLong(fileSize); + this.transferredBytes = new AtomicLong(0); + } + + public long getTotalBytes() { + return totalBytes.get(); + } + + public void setController(SftpController controller) { + this.controller = controller; + } + + public void setFuture(Future future) { + this.future = future; + } + + public void setStatus(String status) { + this.status = status; + if ("running".equals(status) && startedAt == 0) { + startedAt = System.currentTimeMillis(); + } + notifyProgress(); + } + + public void setProgress(long transferred, long total) { + if (startedAt == 0) { + startedAt = System.currentTimeMillis(); + } + transferredBytes.set(Math.max(0, transferred)); + if (total > 0) { + totalBytes.set(Math.max(0, total)); + } + notifyProgress(); + } + + public void markSuccess() { + long total = totalBytes.get(); + if (total > 0) { + transferredBytes.set(total); + } + status = "success"; + finishedAt = System.currentTimeMillis(); + notifyProgress(); + } + + public void markError(String message) { + status = "error"; + error = message; + finishedAt = System.currentTimeMillis(); + notifyProgress(); + } + + private void notifyProgress() { + if (controller != null) { + String taskKey = controller.uploadTaskKey(userId, taskId); + controller.broadcastProgress(taskKey, toResponse()); + } + } + + public Map toResponse() { + long total = totalBytes.get(); + long transferred = transferredBytes.get(); + int progress = total > 0 ? (int) Math.min(100, Math.round((transferred * 100.0) / total)) : + (("success".equals(status) || "error".equals(status)) ? 100 : 0); + + Map result = new HashMap<>(); + result.put("taskId", taskId); + result.put("status", status); + result.put("progress", progress); + result.put("transferredBytes", transferred); + result.put("totalBytes", total); + result.put("filename", filename); + result.put("createdAt", createdAt); + result.put("startedAt", startedAt); + result.put("finishedAt", finishedAt); + if (error != null && !error.isEmpty()) { + result.put("error", error); + } + return result; + } + + public boolean isExpired(long now, long timeoutMillis) { + if ("queued".equals(status) || "running".equals(status)) { + return false; + } + long endTime = finishedAt > 0 ? finishedAt : createdAt; + return now - endTime > timeoutMillis; + } + } } diff --git a/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java index 0965614..fc4d531 100644 --- a/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java @@ -52,8 +52,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } - // WebSocket handshake sends token as query param - if (request.getRequestURI() != null && request.getRequestURI().startsWith("/ws/")) { + // WebSocket handshake and SSE endpoints send token as query param + String uri = request.getRequestURI(); + if (uri != null && (uri.startsWith("/ws/") || uri.contains("/progress"))) { String token = request.getParameter("token"); if (StringUtils.hasText(token)) { return token; diff --git a/backend/src/main/java/com/sshmanager/service/SftpService.java b/backend/src/main/java/com/sshmanager/service/SftpService.java index 40f912b..cb2e3b5 100644 --- a/backend/src/main/java/com/sshmanager/service/SftpService.java +++ b/backend/src/main/java/com/sshmanager/service/SftpService.java @@ -1,26 +1,26 @@ package com.sshmanager.service; -import com.jcraft.jsch.ChannelSftp; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.Session; -import com.jcraft.jsch.SftpException; -import com.sshmanager.entity.Connection; -import org.springframework.stereotype.Service; +import com.jcraft.jsch.ChannelSftp; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import com.jcraft.jsch.SftpATTRS; +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.util.ArrayList; -import java.util.List; -import java.util.Vector; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Vector; +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 { @@ -90,7 +90,7 @@ public class SftpService { } } - public static class FileInfo { + public static class FileInfo { public String name; public boolean directory; public long size; @@ -101,8 +101,14 @@ public class SftpService { this.directory = directory; this.size = size; this.mtime = mtime; - } - } + } + } + + public interface TransferProgressListener { + void onStart(long totalBytes); + + void onProgress(long transferredBytes, long totalBytes); + } public List listFiles(SftpSession sftpSession, String path) throws Exception { String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim(); @@ -166,6 +172,30 @@ public class SftpService { 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 delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception { @@ -196,28 +226,75 @@ 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. */ - public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath) - throws Exception { - if (source.getChannel().stat(sourcePath).isDir()) { - throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported"); - } - final int pipeBufferSize = 65536; - PipedOutputStream pos = new PipedOutputStream(); - PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize); - - Future putFuture = executorService.submit(() -> { - try { - target.getChannel().put(pis, targetPath); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - source.getChannel().get(sourcePath, pos); - pos.close(); - putFuture.get(5, TimeUnit.MINUTES); - try { - pis.close(); - } catch (Exception ignored) { - } - } -} + public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath) + throws Exception { + transferRemote(source, sourcePath, target, targetPath, null); + } + + public void transferRemote(SftpSession source, + String sourcePath, + SftpSession target, + String targetPath, + 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 = attrs.getSize(); + final int pipeBufferSize = 65536; + PipedOutputStream pos = new PipedOutputStream(); + PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize); + AtomicLong transferredBytes = new AtomicLong(0); + + if (progressListener != null) { + progressListener.onStart(totalBytes); + } + + Future putFuture = executorService.submit(() -> { + try { + 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); + } + } + + @Override + public boolean count(long count) { + long current = transferredBytes.addAndGet(count); + if (progressListener != null) { + progressListener.onProgress(current, totalBytes); + } + return true; + } + + @Override + public void end() { + if (progressListener != null) { + progressListener.onProgress(totalBytes, totalBytes); + } + } + }, ChannelSftp.OVERWRITE); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + try { + source.getChannel().get(sourcePath, pos); + } finally { + try { + pos.close(); + } catch (Exception ignored) { + } + } + try { + putFuture.get(); + } finally { + try { + pis.close(); + } catch (Exception ignored) { + } + } + } +} diff --git a/design-system/MASTER.md b/design-system/MASTER.md deleted file mode 100644 index 29de62c..0000000 --- a/design-system/MASTER.md +++ /dev/null @@ -1,38 +0,0 @@ -# SSH 管理器设计系统 - -## 风格 - -- **产品类型**:管理后台 / 开发工具仪表盘 -- **主题**:深色、专业、终端风格 -- **布局**:侧边栏 + 主内容区 - -## 色彩 - -- 背景:slate-900 (#0f172a)、slate-800 -- 表面:slate-800、slate-700 -- 主文字:slate-100 (#f1f5f9) -- 次要文字:slate-400 -- 强调(成功/连接):emerald-500、cyan-500 -- 边框:slate-600、slate-700 - -## 字体 - -- 字体:Inter 或 system-ui -- 正文:最小 16px,行高 1.5 - -## 图标 - -- 仅使用 Lucide 图标,不使用 emoji -- 尺寸:统一 20px 或 24px - -## 交互 - -- 所有可点击元素使用 cursor-pointer -- transition-colors duration-200 -- 最小触控区域 44×44px - -## 无障碍 - -- 对比度 4.5:1 -- 可见焦点环 -- 仅图标按钮需设置 aria-label diff --git a/docs/design-system/MASTER.md b/docs/design-system/MASTER.md new file mode 100644 index 0000000..a09f2c2 --- /dev/null +++ b/docs/design-system/MASTER.md @@ -0,0 +1,43 @@ +# SSH Manager Transfer Console - Design System (Master) + +Goal: a fast, reliable, ops-style UI for moving data across many hosts. + +Design principles +- Transfer-first: primary surface is "plans / queue / progress"; connections are supporting data. +- Dense but calm: show more information without visual noise; consistent rhythm and spacing. +- Failure is actionable: errors are specific, local to the job, and keep context. +- Keyboard-friendly: visible focus rings, logical tab order, no hover-only actions. + +Color and surfaces (dark-first) +- Background: deep slate with subtle gradient + faint grid/noise. +- Surfaces: layered cards (solid + slight transparency) with visible borders. +- Accent: cyan for primary actions and progress. +- Status: + - Success: green + - Warning: amber + - Danger: red + +Typography +- Headings: IBM Plex Sans (600-700) +- Body: IBM Plex Sans (400-500) +- Mono (paths, hostnames, commands): IBM Plex Mono + +Spacing and layout +- App shell: left rail (nav) + main content; content uses max width on desktop. +- Cards: 12-16px padding on mobile, 16-20px on desktop. +- Touch targets: >= 44px for buttons / list rows. + +Interaction +- Buttons: disable during async; show inline spinner + label change ("Starting…"). +- Loading: skeleton for lists; avoid layout jump. +- Motion: 150-250ms transitions; respect prefers-reduced-motion. + +Accessibility +- Contrast: normal text >= 4.5:1. +- Focus: always visible focus ring on interactive elements. +- Icon-only buttons must have aria-label. + +Transfer UX patterns +- "Plan" = input + targets + options; "Run" produces jobs in a queue. +- Queue rows show: source, targets count, status, progress, started/finished, retry. +- Progress: per-target progress when available (XHR upload), otherwise discrete states. diff --git a/frontend/index.html b/frontend/index.html index 2b1fc95..7ba9157 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,13 @@ - SSH 管理器 + + + + SSH 传输控制台
diff --git a/frontend/src/api/sftp.ts b/frontend/src/api/sftp.ts index 8f8f2eb..3f68fed 100644 --- a/frontend/src/api/sftp.ts +++ b/frontend/src/api/sftp.ts @@ -7,6 +7,18 @@ export interface SftpFileInfo { mtime: number } +export interface RemoteTransferTask { + taskId: string + status: 'queued' | 'running' | 'success' | 'error' | 'cancelled' + progress: number + transferredBytes: number + totalBytes: number + error?: string + createdAt: number + startedAt: number + finishedAt: number +} + export function listFiles(connectionId: number, path: string) { return client.get('/sftp/list', { params: { connectionId, path: path || '.' }, @@ -46,50 +58,64 @@ export function uploadFileWithProgress(connectionId: number, path: string, file: xhr.open('POST', url) xhr.setRequestHeader('Authorization', `Bearer ${token}`) - + + // Create a wrapper object to hold the progress callback + const wrapper = { onProgress: undefined as ((percent: number) => void) | undefined } + + // Allow caller to attach handlers after this function returns. xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = Math.round((event.loaded / event.total) * 100) - if ((xhr as any).onProgress) { - (xhr as any).onProgress(percent) - } + console.log('[Upload Progress] event fired:', { lengthComputable: event.lengthComputable, loaded: event.loaded, total: event.total }) + if (!event.lengthComputable) return + const percent = Math.round((event.loaded / event.total) * 100) + console.log('[Upload Progress] percent:', percent, 'hasCallback:', !!wrapper.onProgress) + if (wrapper.onProgress) wrapper.onProgress(percent) + } + + // Defer send so callers can attach onload/onerror/onProgress safely. + setTimeout(() => { + try { + xhr.send(form) + } catch { + // ignore } - } - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - const responseJson = JSON.parse(xhr.responseText) as { message: string } - ;(xhr as any).resolve(responseJson) - } catch { - ;(xhr as any).resolve({ message: 'Uploaded' }) - } - } else { - try { - const responseJson = JSON.parse(xhr.responseText) as { error?: string } - ;(xhr as any).reject(new Error(responseJson.error || `Upload failed: ${xhr.status}`)) - } catch { - ;(xhr as any).reject(new Error(`Upload failed: ${xhr.status}`)) - } - } - } - - xhr.onerror = () => { - ;(xhr as any).reject(new Error('Network error')) - } - - xhr.send(form) - return xhr as XMLHttpRequest & { onProgress?: (percent: number) => void; resolve?: (value: any) => void; reject?: (reason?: any) => void } + }, 0) + + // Return XHR with a setter that updates the wrapper + const result = xhr as XMLHttpRequest & { onProgress?: (percent: number) => void } + Object.defineProperty(result, 'onProgress', { + get: () => wrapper.onProgress, + set: (fn) => { wrapper.onProgress = fn }, + enumerable: true, + configurable: true + }) + return result } export function uploadFile(connectionId: number, path: string, file: File) { const form = new FormData() form.append('file', file, file.name) - return client.post('/sftp/upload', form, { + return client.post<{ taskId: string; message: string }>('/sftp/upload', form, { params: { connectionId, path }, }) } +export interface UploadTask { + taskId: string + status: 'queued' | 'running' | 'success' | 'error' + progress: number + transferredBytes: number + totalBytes: number + filename: string + error?: string + createdAt: number + startedAt: number + finishedAt: number +} + +export function getUploadTask(taskId: string) { + return client.get(`/sftp/upload/tasks/${encodeURIComponent(taskId)}`) +} + export function deleteFile(connectionId: number, path: string, directory: boolean) { return client.delete('/sftp/delete', { params: { connectionId, path, directory }, @@ -123,3 +149,78 @@ export function transferRemote( }, }) } + +export function createRemoteTransferTask( + sourceConnectionId: number, + sourcePath: string, + targetConnectionId: number, + targetPath: string +) { + return client.post('/sftp/transfer-remote/tasks', null, { + params: { + sourceConnectionId, + sourcePath, + targetConnectionId, + targetPath, + }, + }) +} + +export function getRemoteTransferTask(taskId: string) { + return client.get(`/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}`) +} + +export function subscribeRemoteTransferProgress(taskId: string, onProgress: (task: RemoteTransferTask) => void): () => void { + const token = localStorage.getItem('token') + const url = `/api/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}/progress` + const eventSource = new EventSource(`${url}?token=${encodeURIComponent(token || '')}`) + + eventSource.addEventListener('progress', (event) => { + try { + const data = JSON.parse(event.data) + console.log('[SSE] Received progress event:', data) + onProgress(data) + } catch (e) { + console.error('Failed to parse SSE progress data:', e) + } + }) + + eventSource.addEventListener('error', (event) => { + console.error('SSE connection error:', event) + eventSource.close() + }) + + return () => { + eventSource.close() + } +} + +export function subscribeUploadProgress(taskId: string, onProgress: (task: UploadTask) => void): () => void { + const token = localStorage.getItem('token') + const url = `/api/sftp/upload/tasks/${encodeURIComponent(taskId)}/progress` + const eventSource = new EventSource(`${url}?token=${encodeURIComponent(token || '')}`) + + eventSource.addEventListener('progress', (event) => { + try { + const data = JSON.parse(event.data) + onProgress(data) + } catch (e) { + console.error('Failed to parse SSE progress data:', e) + } + }) + + eventSource.addEventListener('error', (event) => { + console.error('SSE connection error:', event) + eventSource.close() + }) + + return () => { + eventSource.close() + } +} + +export function cancelRemoteTransferTask(taskId: string) { + return client.delete( + `/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}` + ) +} diff --git a/frontend/src/components/SftpFilePickerModal.vue b/frontend/src/components/SftpFilePickerModal.vue new file mode 100644 index 0000000..8d482ff --- /dev/null +++ b/frontend/src/components/SftpFilePickerModal.vue @@ -0,0 +1,222 @@ + + + diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 5d4124e..1e0fd6c 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -3,7 +3,7 @@ import { ref } from 'vue' import { RouterLink, useRoute } from 'vue-router' import { useAuthStore } from '../stores/auth' import { useConnectionsStore } from '../stores/connections' -import { Server, LogOut, Menu, X } from 'lucide-vue-next' +import { ArrowLeftRight, Server, LogOut, Menu, X } from 'lucide-vue-next' const route = useRoute() const authStore = useAuthStore() @@ -35,21 +35,31 @@ function closeSidebar() { ]" >
-

SSH 管理器

-

{{ authStore.displayName || authStore.username }}

-
- +

SSH 传输控制台

+

{{ authStore.displayName || authStore.username }}

+ +