# Remote -> Many 多文件传输 Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 为 Remote -> Many 传输模式添加支持选择多个远程文件的能力,所有文件推送到统一目标目录,使用并发传输提升效率。 **Architecture:** 在现有单文件传输架构上扩展,前端 `SftpFilePickerModal.vue` 支持多选模式(v-model 从 string 改为 string[]),`TransfersView.vue` 和 `transfers.ts` 支持批量源文件路径。后端 `createTransferRemoteTask()` 保持单文件任务接口,前端并发创建多个任务并复用现有 SSE 进度追踪机制。 **Tech Stack:** - Frontend: Vue 3 + TypeScript + Pinia - Backend: Spring Boot 2.7 + Java 8 + JSch - SSE (Server-Sent Events) 用于进度追踪 - 并发控制复用 `runWithConcurrency` 工具函数 --- ## 文件结构 | 文件 | 职责 | |------|------| | `frontend/src/components/SftpFilePickerModal.vue` | 支持多选模式,v-model 改为 string[],批量选择文件路径 | | `frontend/src/views/TransfersView.vue` | `remoteSourcePath: string` → `remoteSourcePaths: string[]`,支持文件选择按钮和批量路径输入 | | `frontend/src/stores/transfers.ts` | 新增 `startRemoteToManyMulti()` 并发处理多个源文件,复用现有 `runWithConcurrency` 和 SSE 机制 | | `frontend/src/api/sftp.ts` | 新增 `createRemoteToManyMultiTask()` 批量创建远程传输任务(可选,可直接复用 `createRemoteTransferTask`) | | `backend/src/main/java/com/sshmanager/controller/SftpController.java` | 新增 `createRemoteToManyMultiTask()` 支持 `sourcePaths: String[]`,每个文件创建独立任务 | | `backend/src/main/java/com/sshmanager/service/SftpService.java` | 无改动(`transferRemote()` 已支持单文件,复用即可) | --- ## 任务分解 ### Task 0: 前端基础改造准备 **Files:** - Modify: `frontend/src/components/SftpFilePickerModal.vue:1-160` - Modify: `frontend/src/views/TransfersView.vue:34-152` - [ ] **Step 1: 修改 SftpFilePickerModal.vue 支持多选模式** ```vue ``` - [ ] **Step 2: 修改 TransfersView.vue 支持多文件源路径** ```vue ``` - [ ] **Step 3: 运行前端类型检查** ```bash cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/frontend && npm run build ``` Expected: Build succeeds with no TypeScript errors in modified files. - [ ] **Step 4: 浏览器验证 UI 变更** - 手动启动前端开发服务 `npm run dev` - 切换到 Remote -> Many 标签页 - 点击 "浏览" 按钮,验证文件选择器支持多选(单击切换选中状态,右上角确认按钮显示选中数量) - 选中多个文件后,验证源路径输入框显示路径列表,再次点击浏览按钮可追加选择 - 清空按钮可清空已选择的路径 --- ### Task 1: 后端新增批量任务创建 API **Files:** - Modify: `backend/src/main/java/com/sshmanager/controller/SftpController.java:504-547` - [ ] **Step 1: 修改 SftpController.createTransferRemoteTask() 支持多源路径** ```java @PostMapping("/transfer-remote/batch-tasks") public ResponseEntity> createRemoteToManyMultiTask( @RequestParam Long sourceConnectionId, @RequestParam String[] sourcePaths, @RequestParam Long targetConnectionId, @RequestParam String targetDirOrPath, Authentication authentication) { Long userId = getCurrentUserId(authentication); if (sourcePaths == null || sourcePaths.length == 0) { Map err = new HashMap<>(); err.put("error", "sourcePaths is required"); return ResponseEntity.badRequest().body(err); } if (sourceConnectionId == null) { Map err = new HashMap<>(); err.put("error", "sourceConnectionId is required"); return ResponseEntity.badRequest().body(err); } if (targetConnectionId == null) { Map err = new HashMap<>(); err.put("error", "targetConnectionId is required"); return ResponseEntity.badRequest().body(err); } if (targetDirOrPath == null || targetDirOrPath.trim().isEmpty()) { Map err = new HashMap<>(); err.put("error", "targetDirOrPath is required"); return ResponseEntity.badRequest().body(err); } List> taskResponses = new ArrayList<>(); for (String sourcePath : sourcePaths) { ResponseEntity> validation = validateTransferPaths(sourcePath, targetDirOrPath); if (validation != null) { Map err = new HashMap<>(); err.putAll(validation.getBody()); err.put("sourcePath", sourcePath); taskResponses.add(err); continue; } String sourcePathTrimmed = sourcePath.trim(); String filename = sourcePathTrimmed.split("/").filter(p -> !p.isEmpty()).reduce((a, b) -> b).orElse(sourcePathTrimmed); String targetPath = targetDirOrPath.endsWith("/") ? (targetDirOrPath + filename) : targetDirOrPath; TransferTaskStatus status = new TransferTaskStatus(UUID.randomUUID().toString(), userId, sourceConnectionId, targetConnectionId, sourcePathTrimmed, targetPath); 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, sourcePathTrimmed, targetConnectionId, targetPath, status); status.markSuccess(); } catch (Exception e) { if (e instanceof InterruptedException || Thread.currentThread().isInterrupted()) { status.markCancelled(); return; } status.markError(toSftpErrorMessage(e, sourcePathTrimmed, "transfer")); log.warn("SFTP transfer task failed: taskId={}, sourceConnectionId={}, sourcePath={}, targetConnectionId={}, targetPath={}, error={}", status.getTaskId(), sourceConnectionId, sourcePathTrimmed, targetConnectionId, targetPath, e.getMessage(), e); } }); status.setFuture(future); taskResponses.add(status.toResponse()); } Map result = new HashMap<>(); result.put("tasks", taskResponses); result.put("count", taskResponses.size()); return ResponseEntity.ok(result); } ``` - [ ] **Step 2: 修改 SftpController.createTransferRemoteTask() 保持单文件兼容性** ```java @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()); } ``` - [ ] **Step 3: 运行后端编译测试** ```bash cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend && mvn clean compile ``` Expected: Compilation succeeds with no errors. - [ ] **Step 4: 启动后端并验证 API** ```bash cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend && mvn spring-boot:run ``` - 验证 `/api/sftp/transfer-remote/tasks` 接口(单文件)仍正常工作 - 验证 `/api/sftp/transfer-remote/batch-tasks` 接口存在,可通过 curl 验证: ```bash curl -X POST "http://localhost:48080/api/sftp/transfer-remote/batch-tasks" \ -H "Authorization: Bearer " \ -d "sourceConnectionId=1&sourcePaths=/path/file1.txt&sourcePaths=/path/file2.txt&targetConnectionId=2&targetDirOrPath=/target/" ``` Expected: Response includes `tasks` array with individual task status. --- ### Task 2: 前端 API 封装和 Store 改造 **Files:** - Modify: `frontend/src/api/sftp.ts:226-230` - Modify: `frontend/src/stores/transfers.ts:368-410` - [ ] **Step 1: 添加 createRemoteToManyMultiTask API** ```typescript export function createRemoteToManyMultiTask( sourceConnectionId: number, sourcePaths: string[], targetConnectionId: number, targetDirOrPath: string ) { const params = new URLSearchParams() params.append('sourceConnectionId', String(sourceConnectionId)) sourcePaths.forEach((p) => params.append('sourcePaths', p)) params.append('targetConnectionId', String(targetConnectionId)) params.append('targetDirOrPath', targetDirOrPath) return client.post<{ tasks: RemoteTransferTask[]; count: number }>('/sftp/transfer-remote/batch-tasks', null, { params, }) } ``` - [ ] **Step 2: 新增 startRemoteToManyMulti store method** ```typescript async function startRemoteToManyMulti(params: { sourceConnectionId: number sourcePaths: string[] targetConnectionIds: number[] targetDirOrPath: string concurrency?: number }) { const { sourceConnectionId, sourcePaths, targetConnectionIds, targetDirOrPath } = params const concurrency = params.concurrency ?? 3 if (sourceConnectionId == null) return const runId = uid('run') const runItems: TransferItem[] = [] for (const sourcePath of sourcePaths) { const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath for (const targetId of targetConnectionIds) { runItems.push({ id: uid('item'), label: `#${sourceConnectionId}:${sourcePath} -> #${targetId}:${targetDirOrPath}`, status: 'queued' as const, progress: 0, }) } } const run: TransferRun = { id: runId, mode: 'REMOTE_TO_MANY' as const, title: `Remote ${sourcePaths.length} files -> ${targetConnectionIds.length} targets`, createdAt: now(), items: runItems, status: 'queued' as const, } runs.value = [run, ...runs.value] let cancelled = false const unsubscribers: (() => void)[] = [] controllers.set(runId, { abortAll: () => { cancelled = true }, unsubscribers, }) const tasks: (() => Promise)[] = sourcePaths.flatMap((sourcePath) => { const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath return targetConnectionIds.map((targetId, index) => { const itemIndex = runItems.findIndex((i) => i.label.includes(filename) && i.label.includes(`#${targetId}`) ) if (itemIndex === -1) return null const item = runItems[itemIndex]! return async () => { if (item.status === 'cancelled' || cancelled) return item.status = 'running' item.progress = 0 item.startedAt = now() runs.value = [...runs.value] console.log('[Remote->Many Multi] Starting transfer:', item.label, 'targetId:', targetId) try { const targetPath = targetDirOrPath.endsWith('/') ? targetDirOrPath + filename : targetDirOrPath const task = await createRemoteToManyMultiTask(sourceConnectionId, [sourcePath], targetId, targetPath) const taskId = task.data.tasks[0]?.taskId if (!taskId) { throw new Error('Failed to create transfer task') } console.log('[Remote->Many Multi] Task created:', taskId) await waitForRemoteTransfer(taskId, (progress) => { console.log('[Remote->Many Multi] Progress update:', progress, 'item:', item.label) item.progress = Math.max(item.progress || 0, progress) runs.value = [...runs.value] }, unsubscribers) item.status = 'success' item.progress = 100 item.finishedAt = now() console.log('[Remote->Many Multi] Transfer completed:', item.label) runs.value = [...runs.value] } catch (e: unknown) { const err = e as { response?: { data?: { error?: string } } } const msg = err?.response?.data?.error || (e as Error)?.message || 'Transfer failed' console.error('[Remote->Many Multi] Transfer failed:', item.label, 'error:', msg) if (msg === 'Cancelled') { item.status = 'cancelled' item.progress = 100 } else { item.status = 'error' item.progress = 100 item.message = msg } item.finishedAt = now() runs.value = [...runs.value] } finally { runs.value = [...runs.value] } } }).filter((t): t is () => Promise => t !== null) }) await runWithConcurrency(tasks, concurrency) runs.value = [...runs.value] } ``` - [ ] **Step 3: 在 transfers.ts export 中添加新方法** ```typescript return { runs, recentRuns, controllers, clearRuns, cancelRun, startLocalToMany, startRemoteToMany, startRemoteToManyMulti, } ``` - [ ] **Step 4: 运行前端类型检查** ```bash cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/frontend && npm run build ``` Expected: Build succeeds with no TypeScript errors. --- ### Task 3: 端到端测试和验证 **Files:** - Manual verification only - [ ] **Step 1: 清空数据库并重启后端** ```bash cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend rm -rf data/sshmanager mvn spring-boot:run ``` - [ ] **Step 2: 启动前端** ```bash cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/frontend npm run dev ``` - [ ] **Step 3: 创建测试连接** - 在 Connections 页面添加 2-3 个测试连接(例如本地 Docker 容器) - [ ] **Step 4: Remote -> Many 单文件传输(兼容性测试)** - 切换到 Remote -> Many 标签页 - 选择源连接 - 使用文件选择器选择单个文件(或手动输入路径) - 选择一个目标连接 - 点击 "开始转发" - 验证任务创建成功,进度栏正常更新 - [ ] **Step 5: Remote -> Many 多文件传输(新增功能)** - 切换到 Remote -> Many 标签页 - 点击 "浏览" 按钮 - 在文件选择器中: - 单击多个文件切换选中状态 - 右上角 "确认 (N)" 按钮应显示选中数量 - 选中 2-3 个文件后点击确认 - 源路径输入框应显示类似 `/path/file1.txt, /path/file2.txt` - 选择一个目标连接 - 点击 "开始转发" - 验证任务队列中创建了 `sourceFiles.length * targetConnections.length` 个子任务 - 每个子任务的进度独立更新 - 全部完成后状态应为 "Success" - [ ] **Step 6: 并发控制验证** - 设置并发为 1(串行) - 选择 3 个源文件和 2 个目标连接(共 6 个子任务) - 观察任务执行顺序,应为串行执行 - 设置并发为 3,重复以上步骤,观察并发执行 - [ ] **Step 7: 错误处理验证** - 选择不存在的文件路径 - 验证错误消息正确显示在对应子任务中 - 其他子任务不受影响继续执行 - [ ] **Step 8: 取消任务验证** - 开始传输后立即点击 "取消任务" - 验证所有未完成任务标记为 "Cancelled" - 控制台日志显示取消信息 - [ ] **Step 9: 登录流程验证** - 退出登录 - 重新登录 - 创建新的传输任务 - 验证认证流程正常 --- ### Task 4: 文档和清理 **Files:** - Create: `docs/REMOTE_MULTI_FILE_TRANSFER.md` - [ ] **Step 1: 创建功能文档** ```markdown # Remote -> Many 多文件传输功能说明 ## 功能概述 Remote -> Many 模式现在支持选择多个远程文件,所有文件推送到统一目标目录,使用并发传输提升效率。 ## 使用方式 ### 前端操作 1. 切换到 Remote -> Many 标签页 2. 选择源连接 3. 点击 "浏览" 按钮打开文件选择器 4. 在文件选择器中: - 单击文件切换选中状态 - 选择多个文件后点击右上角 "确认 (N)" 按钮 - 或手动在源路径输入框输入路径(英文逗号分隔) 5. 选择一个或多个目标连接 6. 设置并发数(默认 3) 7. 点击 "开始转发" ### 并发控制 - 并发数支持 1-6,建议 2-4 - 并发越高越吃带宽与 CPU - 后端是逐个调用 transfer-remote;并发适中即可 ### 进度追踪 - 每个文件-目标对独立显示进度 - 悬停可查看详细错误信息 - 支持取消所有未完成任务 ## 技术细节 ### API 端点 - `POST /api/sftp/transfer-remote/batch-tasks` - 批量创建传输任务 - `sourceConnectionId` - 源连接 ID - `sourcePaths` - 源文件路径数组(可重复) - `targetConnectionId` - 目标连接 ID - `targetDirOrPath` - 目标目录或路径 - `GET /api/sftp/transfer-remote/tasks/{taskId}/progress` - SSE 进度追踪 ### 并发控制 - 前端使用 `runWithConcurrency` 工具函数 - 每个文件-目标对创建独立任务 - 任务状态独立追踪,互不影响 ### 错误处理 - 单个文件传输失败不影响其他文件 - 错误信息显示在对应子任务中 - 支持取消未完成任务 ``` - [ ] **Step 2: 运行最终构建验证** ```bash cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager && docker compose -f docker/docker-compose.yml up --build -d ``` Expected: Docker service starts successfully with no errors. - [ ] **Step 3: 查看日志** ```bash docker compose -f docker/docker-compose.yml logs -f ``` Verify: No stack traces or critical errors in application logs. - [ ] **Step 4: 提交代码** ```bash cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager git add -A git commit -m "feat: add multi-file support for Remote -> Many transfer mode" ``` --- ## 执行摘要 | 阶段 | 任务 | 预计时间 | |------|------|----------| | 前端基础改造 | Task 0 | 15 分钟 | | 后端 API 扩展 | Task 1 | 10 分钟 | | Store 改造 | Task 2 | 10 分钟 | | 端到端测试 | Task 3 | 20 分钟 | | 文档和清理 | Task 4 | 5 分钟 | | **总计** | | **60 分钟** | --- ## 完成检查项 - [ ] 前端 UI 支持多文件选择(`SftpFilePickerModal.vue` 多选模式) - [ ] `TransfersView.vue` 支持 `remoteSourcePaths: string[]` - [ ] `transfers.ts` 新增 `startRemoteToManyMulti()` 方法 - [ ] 后端新增 `/api/sftp/transfer-remote/batch-tasks` 接口 - [ ] 每个文件-目标对独立任务,共享 SSE 进度追踪 - [ ] 并发控制复用 `runWithConcurrency` 工具函数 - [ ] 前端类型检查通过(`npm run build`) - [ ] 后端编译通过(`mvn clean compile`) - [ ] Docker 构建成功,服务启动正常 - [ ] 端到端测试通过(单文件兼容性、多文件传输、并发控制、错误处理、取消任务、登录流程) - [ ] 功能文档已创建 - [ ] 代码已提交