Files
ssh-manager/docs/superpowers/plans/2026-03-20-remote-multi-file-transfer.md
liumangmang c8fa3de679 docs: 添加 superpowers 规格与计划文档
记录 Remote->Many 多文件与相关实现计划/设计,便于后续追踪与复盘。
2026-03-24 13:43:08 +08:00

1400 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as sftpApi from '../api/sftp'
import type { SftpFileInfo } from '../api/sftp'
import { X, FolderOpen, File, ChevronRight, RefreshCw, Eye, EyeOff, Check } from 'lucide-vue-next'
const props = defineProps<{ open: boolean; connectionId: number | null; multi?: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'select', paths: string[] | string): void
}>()
const propsMulti = computed(() => props.multi ?? false)
const currentPath = ref('.')
const pathParts = ref<string[]>([])
const files = ref<SftpFileInfo[]>([])
const loading = ref(false)
const error = ref('')
const showHiddenFiles = ref(false)
const searchQuery = ref('')
let searchDebounceTimer = 0
const filteredFiles = ref<SftpFileInfo[]>([])
const selectedFiles = ref<string[]>([])
const canInteract = computed(() => props.open && props.connectionId != null)
function applyFileFilters() {
const q = searchQuery.value.trim().toLowerCase()
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
filteredFiles.value = q ? base.filter((f) => f.name.toLowerCase().includes(q)) : base
}
watch([searchQuery, showHiddenFiles, files], () => {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
applyFileFilters()
}, 300)
}, { immediate: true })
async function initPath() {
if (!canInteract.value || props.connectionId == null) return
error.value = ''
try {
const res = await sftpApi.getPwd(props.connectionId)
const p = res.data.path || '/'
currentPath.value = p === '/' ? '/' : p
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
} catch (e: unknown) {
currentPath.value = '.'
pathParts.value = []
}
}
async function load() {
if (!canInteract.value || props.connectionId == null) return
loading.value = true
error.value = ''
searchQuery.value = ''
try {
const res = await sftpApi.listFiles(props.connectionId, currentPath.value)
files.value = res.data
.slice()
.sort((a, b) => {
if (a.directory !== b.directory) return a.directory ? -1 : 1
return a.name.localeCompare(b.name)
})
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } }
error.value = err?.response?.data?.error ?? '获取文件列表失败'
} finally {
loading.value = false
}
}
function navigateToDir(name: string) {
if (loading.value) return
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
load()
}
function navigateToIndex(i: number) {
if (loading.value) return
if (i < 0) {
currentPath.value = '.'
} else {
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
}
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
load()
}
function filePath(file: SftpFileInfo) {
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
return base ? base.replace(/\/$/, '') + '/' + file.name : file.name
}
function toggleFileSelection(file: SftpFileInfo) {
if (file.directory) return
const fp = filePath(file)
const idx = selectedFiles.value.indexOf(fp)
if (idx === -1) {
selectedFiles.value.push(fp)
} else {
selectedFiles.value.splice(idx, 1)
}
}
function handleClick(file: SftpFileInfo) {
if (file.directory) {
navigateToDir(file.name)
return
}
if (propsMulti.value) {
toggleFileSelection(file)
} else {
emit('select', filePath(file))
emit('close')
}
}
function emitSelection() {
if (selectedFiles.value.length === 0) return
emit('select', propsMulti.value ? selectedFiles.value : selectedFiles.value[0])
emit('close')
}
watch(
() => [props.open, props.connectionId] as const,
async ([open]) => {
if (!open) return
await initPath()
await load()
}
)
function onKeyDown(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('close')
}
onMounted(() => window.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown)
clearTimeout(searchDebounceTimer)
})
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-50 bg-black/60 p-4 flex items-center justify-center" role="dialog" aria-modal="true">
<div class="w-full max-w-3xl rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur shadow-2xl overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<div class="min-w-0">
<h3 class="text-slate-100 font-semibold truncate">{{ propsMulti ? '选择多个源文件' : '选择源文件' }}</h3>
<p class="text-xs text-slate-400 truncate">{{ propsMulti ? '多选:单击文件切换选中状态,双击打开目录' : '单击即选择' }}</p>
</div>
<div class="flex items-center gap-2">
<button
@click="load"
:disabled="loading || !canInteract"
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 disabled:opacity-50 cursor-pointer transition-colors"
aria-label="刷新"
>
<span class="inline-flex items-center gap-2">
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
刷新
</span>
</button>
<button
v-if="propsMulti && selectedFiles.length > 0"
@click="emitSelection"
:disabled="selectedFiles.length === 0"
class="min-h-[44px] px-3 rounded-lg border border-cyan-600 bg-cyan-600 text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition-colors"
aria-label="确认选择"
>
<span class="inline-flex items-center gap-2">
<Check class="w-4 h-4" aria-hidden="true" />
确认 ({{ selectedFiles.length }})
</span>
</button>
<button
@click="emit('close')"
class="min-h-[44px] w-11 grid place-items-center rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 cursor-pointer transition-colors"
aria-label="关闭"
>
<X class="w-5 h-5" aria-hidden="true" />
</button>
</div>
</div>
<div class="px-4 py-3 border-b border-slate-700 bg-slate-900/40">
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0">
<button
@click="navigateToIndex(-1)"
class="px-2 py-1 rounded hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer truncate"
>
/
</button>
<template v-for="(part, i) in pathParts" :key="i">
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" aria-hidden="true" />
<button
@click="navigateToIndex(i)"
class="px-2 py-1 rounded hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer truncate max-w-[140px]"
>
{{ part || '/' }}
</button>
</template>
</nav>
<div class="mt-3 flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
class="flex-1 rounded-lg border border-slate-600 bg-slate-900/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
placeholder="搜索文件..."
aria-label="搜索文件"
/>
<button
@click="showHiddenFiles = !showHiddenFiles"
class="min-h-[44px] p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors cursor-pointer"
:aria-label="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
:title="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
>
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<p v-if="error" class="mt-2 text-sm text-red-400">{{ error }}</p>
</div>
<div class="max-h-[60vh] overflow-auto divide-y divide-slate-800">
<button
v-for="file in filteredFiles"
:key="file.name"
@click="handleClick(file)"
class="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-800/40 transition-colors cursor-pointer min-h-[44px]"
:class="selectedFiles.includes(filePath(file)) ? 'bg-cyan-500/10' : ''"
:aria-label="file.directory ? '打开目录' : (propsMulti ? '切换选中状态' : '选择文件')"
>
<Check
v-if="propsMulti && selectedFiles.includes(filePath(file))"
class="w-5 h-5 flex-shrink-0 text-cyan-300"
aria-hidden="true"
/>
<component
v-else
:is="file.directory ? FolderOpen : File"
class="w-5 h-5 flex-shrink-0"
:class="file.directory ? 'text-cyan-300' : 'text-slate-300'"
aria-hidden="true"
/>
<span class="flex-1 min-w-0 truncate text-slate-100">{{ file.name }}</span>
<span v-if="!file.directory" class="text-xs text-slate-500">{{ Math.round(file.size / 1024) }} KB</span>
</button>
<div v-if="filteredFiles.length === 0 && !loading" class="px-4 py-10 text-center text-slate-500">
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件已隐藏文件') }}
</div>
</div>
</div>
</div>
</Teleport>
</template>
```
- [ ] **Step 2: 修改 TransfersView.vue 支持多文件源路径**
```vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useConnectionsStore } from '../stores/connections'
import { useTransfersStore } from '../stores/transfers'
import SftpFilePickerModal from '../components/SftpFilePickerModal.vue'
import {
ArrowUpRight,
ArrowLeftRight,
CloudUpload,
FolderOpen,
XCircle,
CheckCircle2,
AlertTriangle,
Loader2,
Trash2,
Plus,
} from 'lucide-vue-next'
type Tab = 'local' | 'remote'
const connectionsStore = useConnectionsStore()
const transfersStore = useTransfersStore()
const tab = ref<Tab>('local')
// Local -> many
const localFiles = ref<File[]>([])
const localTargetDir = ref('/')
const localSelectedTargets = ref<number[]>([])
const localConcurrency = ref(3)
// Remote -> many
const remoteSourceConnectionId = ref<number | null>(null)
const remoteSourcePaths = ref<string[]>([])
const remoteTargetDirOrPath = ref('/')
const remoteSelectedTargets = ref<number[]>([])
const remoteConcurrency = ref(3)
// Picker
const pickerOpen = ref(false)
const pickerMulti = ref(false)
const connections = computed(() => connectionsStore.connections)
const connectionOptions = computed(() => connections.value.slice().sort((a, b) => a.name.localeCompare(b.name)))
// Remote -> Many 模式下的目标连接列表(排除源连接)
const remoteTargetConnectionOptions = computed(() =>
connectionOptions.value.filter((c) => c.id !== remoteSourceConnectionId.value)
)
const canStartLocal = computed(() => localFiles.value.length > 0 && localSelectedTargets.value.length > 0)
const canStartRemote = computed(
() => remoteSourceConnectionId.value != null && remoteSourcePaths.value.length > 0 && remoteSelectedTargets.value.length > 0
)
function onLocalFileChange(e: Event) {
const input = e.target as HTMLInputElement
const list = input.files
if (!list) return
localFiles.value = Array.from(list)
}
function selectAllLocalTargets() {
localSelectedTargets.value = connectionOptions.value.map((c) => c.id)
}
function clearLocalTargets() {
localSelectedTargets.value = []
}
function selectAllRemoteTargets() {
remoteSelectedTargets.value = remoteTargetConnectionOptions.value.map((c) => c.id)
}
function clearRemoteTargets() {
remoteSelectedTargets.value = []
}
function humanRunStatus(status: string) {
if (status === 'queued') return 'Queued'
if (status === 'running') return 'Running'
if (status === 'success') return 'Success'
if (status === 'error') return 'Error'
if (status === 'cancelled') return 'Cancelled'
return status
}
function runBadgeClass(status: string) {
if (status === 'success') return 'bg-emerald-500/10 text-emerald-200 border-emerald-500/20'
if (status === 'error') return 'bg-red-500/10 text-red-200 border-red-500/20'
if (status === 'running') return 'bg-cyan-500/10 text-cyan-200 border-cyan-500/20'
if (status === 'cancelled') return 'bg-slate-500/10 text-slate-200 border-slate-500/20'
return 'bg-amber-500/10 text-amber-200 border-amber-500/20'
}
function runProgressPercent(run: { items: { status: string; progress?: number }[]; lastUpdate?: number }) {
// Access lastUpdate to ensure reactivity
void run.lastUpdate
const items = run.items
if (!items.length) return 0
let sum = 0
for (const it of items) {
if (it.status === 'success' || it.status === 'error' || it.status === 'cancelled') {
sum += 1
continue
}
if (it.status === 'running') {
const p = typeof it.progress === 'number' ? it.progress : 0
sum += Math.max(0, Math.min(1, p / 100))
continue
}
// queued
sum += 0
}
return Math.round((sum / items.length) * 100)
}
async function startLocal() {
if (!canStartLocal.value) return
await transfersStore.startLocalToMany({
files: localFiles.value,
targetConnectionIds: localSelectedTargets.value,
targetDir: localTargetDir.value,
concurrency: localConcurrency.value,
})
}
async function startRemote() {
if (!canStartRemote.value || remoteSourceConnectionId.value == null) return
await transfersStore.startRemoteToManyMulti({
sourceConnectionId: remoteSourceConnectionId.value,
sourcePaths: remoteSourcePaths.value,
targetConnectionIds: remoteSelectedTargets.value,
targetDirOrPath: remoteTargetDirOrPath.value,
concurrency: remoteConcurrency.value,
})
}
function openPicker() {
if (remoteSourceConnectionId.value == null) return
pickerMulti.value = true
pickerOpen.value = true
}
function onPickerSelect(paths: string[] | string) {
if (typeof paths === 'string') {
remoteSourcePaths.value = [paths]
} else {
remoteSourcePaths.value = paths
}
pickerOpen.value = false
}
onMounted(async () => {
if (connectionsStore.connections.length === 0) {
await connectionsStore.fetchConnections().catch(() => {})
}
if (remoteSourceConnectionId.value == null) {
remoteSourceConnectionId.value = connectionOptions.value[0]?.id ?? null
}
})
</script>
<template>
<div class="p-6 lg:p-8">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight text-slate-50">Transfers</h1>
<p class="text-sm text-slate-400">本机 -> 多台 / 其他机器 -> 多台</p>
</div>
<button
@click="transfersStore.clearRuns"
class="min-h-[44px] inline-flex items-center gap-2 px-3 rounded-lg border border-slate-700 bg-slate-900/40 text-slate-200 hover:bg-slate-800/60 transition-colors cursor-pointer"
aria-label="清空队列"
>
<Trash2 class="w-4 h-4" aria-hidden="true" />
清空队列
</button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<button
@click="tab = 'local'"
class="rounded-2xl border p-4 text-left transition-colors cursor-pointer min-h-[88px]"
:class="tab === 'local' ? 'border-cyan-500/40 bg-slate-900/55' : 'border-slate-800 bg-slate-900/35 hover:bg-slate-900/45'"
aria-label="切换到本机上传"
>
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/20 grid place-items-center">
<CloudUpload class="w-5 h-5 text-cyan-200" aria-hidden="true" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-slate-100">Local -> Many</p>
<p class="text-xs text-slate-400">选择本机文件分发到多个目标连接</p>
</div>
</div>
</button>
<button
@click="tab = 'remote'"
class="rounded-2xl border p-4 text-left transition-colors cursor-pointer min-h-[88px]"
:class="tab === 'remote' ? 'border-cyan-500/40 bg-slate-900/55' : 'border-slate-800 bg-slate-900/35 hover:bg-slate-900/45'"
aria-label="切换到远程转发"
>
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/20 grid place-items-center">
<ArrowLeftRight class="w-5 h-5 text-cyan-200" aria-hidden="true" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-slate-100">Remote -> Many</p>
<p class="text-xs text-slate-400">从一台机器取文件推送到多个目标连接</p>
</div>
</div>
</button>
</div>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[1fr_460px]">
<section class="rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur p-5">
<div v-if="connections.length === 0" class="text-slate-300">
<p class="text-sm">暂无连接请先在 Connections 里添加连接</p>
</div>
<div v-else>
<div v-if="tab === 'local'" class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-100">Local -> Many</h2>
<span class="text-xs text-slate-400">并发: {{ localConcurrency }}</span>
</div>
<div class="grid gap-3">
<label class="text-sm text-slate-300">选择本机文件</label>
<input
type="file"
multiple
class="block w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-200 file:mr-3 file:rounded-lg file:border-0 file:bg-slate-800 file:px-3 file:py-2 file:text-slate-200 hover:file:bg-slate-700"
@change="onLocalFileChange"
aria-label="选择本机文件"
/>
<p class="text-xs text-slate-500">
已选择 {{ localFiles.length }} 个文件
<span v-if="localFiles.length">(只支持文件目录请先打包)</span>
</p>
</div>
<div class="grid gap-3">
<label for="local-target-dir" class="text-sm text-slate-300">目标目录</label>
<input
id="local-target-dir"
v-model="localTargetDir"
type="text"
placeholder="/"
class="w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<div class="grid gap-3">
<div class="flex items-center justify-between">
<label class="text-sm text-slate-300">目标连接</label>
<div class="flex items-center gap-2">
<button
@click="selectAllLocalTargets"
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
>
全选
</button>
<button
@click="clearLocalTargets"
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
>
清空
</button>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<label
v-for="c in connectionOptions"
:key="c.id"
class="flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-950/20 px-3 py-2 min-h-[44px] cursor-pointer hover:bg-slate-950/30"
>
<input
v-model.number="localSelectedTargets"
:value="c.id"
type="checkbox"
class="h-4 w-4 rounded border-slate-600 bg-slate-900 text-cyan-500 focus:ring-cyan-500"
/>
<div class="min-w-0">
<p class="text-sm text-slate-100 truncate">{{ c.name }}</p>
<p class="text-xs text-slate-500 truncate">{{ c.username }}@{{ c.host }}:{{ c.port }}</p>
</div>
</label>
</div>
</div>
<div class="grid gap-2">
<label for="local-concurrency" class="text-sm text-slate-300">并发</label>
<input
id="local-concurrency"
v-model.number="localConcurrency"
type="range"
min="1"
max="6"
class="w-full"
/>
<p class="text-xs text-slate-500">建议 2-4并发越高越吃带宽与 CPU</p>
</div>
<button
@click="startLocal"
:disabled="!canStartLocal"
class="min-h-[44px] inline-flex items-center justify-center gap-2 rounded-xl bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="开始上传"
>
<ArrowUpRight class="w-4 h-4" aria-hidden="true" />
开始分发
</button>
</div>
<div v-else class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-100">Remote -> Many</h2>
<span class="text-xs text-slate-400">并发: {{ remoteConcurrency }}</span>
</div>
<div class="grid gap-3">
<label for="remote-source-conn" class="text-sm text-slate-300">源连接</label>
<select
id="remote-source-conn"
v-model.number="remoteSourceConnectionId"
class="w-full min-h-[44px] rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
>
<option v-for="c in connectionOptions" :key="c.id" :value="c.id">{{ c.name }} ({{ c.username }}@{{ c.host }})</option>
</select>
</div>
<div class="grid gap-3">
<label for="remote-source-paths" class="text-sm text-slate-300">源文件路径多选</label>
<div class="flex gap-2">
<input
id="remote-source-paths"
:value="remoteSourcePaths.join(', ')"
type="text"
placeholder="选择/输入多个文件路径,用英文逗号分隔"
class="flex-1 rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
readonly
aria-readonly="true"
/>
<button
@click="openPicker"
:disabled="remoteSourceConnectionId == null"
class="min-h-[44px] px-3 rounded-xl border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 disabled:opacity-50 cursor-pointer transition-colors"
aria-label="浏览远程文件"
>
<span class="inline-flex items-center gap-2">
<FolderOpen class="w-4 h-4" aria-hidden="true" />
浏览
</span>
</button>
<button
v-if="remoteSourcePaths.length > 0"
@click="remoteSourcePaths = []"
class="min-h-[44px] px-3 rounded-xl border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 cursor-pointer transition-colors"
aria-label="清空源路径"
>
<span class="inline-flex items-center gap-2">
<XCircle class="w-4 h-4" aria-hidden="true" />
清空
</span>
</button>
</div>
<p v-if="remoteSourcePaths.length > 0" class="text-xs text-cyan-400">
已选择 {{ remoteSourcePaths.length }} 个文件
</p>
</div>
<div class="grid gap-3">
<label for="remote-target-dir" class="text-sm text-slate-300">目标目录或路径</label>
<input
id="remote-target-dir"
v-model="remoteTargetDirOrPath"
type="text"
placeholder="/target/dir/"
class="w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<p class="text-xs text-slate-500"> / 结尾视为目录会自动拼接文件名</p>
</div>
<div class="grid gap-3">
<div class="flex items-center justify-between">
<label class="text-sm text-slate-300">目标连接</label>
<div class="flex items-center gap-2">
<button
@click="selectAllRemoteTargets"
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
>
全选
</button>
<button
@click="clearRemoteTargets"
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
>
清空
</button>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<label
v-for="c in remoteTargetConnectionOptions"
:key="c.id"
class="flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-950/20 px-3 py-2 min-h-[44px] cursor-pointer hover:bg-slate-950/30"
>
<input
v-model.number="remoteSelectedTargets"
:value="c.id"
type="checkbox"
class="h-4 w-4 rounded border-slate-600 bg-slate-900 text-cyan-500 focus:ring-cyan-500"
/>
<div class="min-w-0">
<p class="text-sm text-slate-100 truncate">{{ c.name }}</p>
<p class="text-xs text-slate-500 truncate">{{ c.username }}@{{ c.host }}:{{ c.port }}</p>
</div>
</label>
</div>
<p v-if="remoteTargetConnectionOptions.length === 0" class="text-xs text-amber-400">
没有可用的目标连接源连接已自动排除
</p>
</div>
<div class="grid gap-2">
<label for="remote-concurrency" class="text-sm text-slate-300">并发</label>
<input
id="remote-concurrency"
v-model.number="remoteConcurrency"
type="range"
min="1"
max="6"
class="w-full"
/>
<p class="text-xs text-slate-500">后端是逐个调用 transfer-remote并发适中即可</p>
</div>
<button
@click="startRemote"
:disabled="!canStartRemote"
class="min-h-[44px] inline-flex items-center justify-center gap-2 rounded-xl bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="开始远程转发"
>
<ArrowUpRight class="w-4 h-4" aria-hidden="true" />
开始转发
</button>
</div>
</div>
</section>
<aside class="rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur p-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-100">Queue</h2>
<span class="text-xs text-slate-500">最近 20 </span>
</div>
<div v-if="transfersStore.recentRuns.length === 0" class="mt-4 text-sm text-slate-500">
暂无任务创建一个 plan 然后开始
</div>
<div v-else class="mt-4 space-y-3">
<div
v-for="run in transfersStore.recentRuns"
:key="run.id"
class="rounded-2xl border border-slate-800 bg-slate-950/20 p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-slate-100 truncate">{{ run.title }}</p>
<p class="mt-0.5 text-xs text-slate-500 truncate">{{ new Date(run.createdAt).toLocaleString() }}</p>
</div>
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded-full border" :class="runBadgeClass(run.status)">
{{ humanRunStatus(run.status) }}
</span>
<button
v-if="run.status === 'running' || run.status === 'queued'"
@click="transfersStore.cancelRun(run.id)"
class="w-10 h-10 grid place-items-center rounded-lg border border-slate-800 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer"
aria-label="取消任务"
>
<XCircle class="w-5 h-5" aria-hidden="true" />
</button>
</div>
</div>
<div class="mt-3">
<div class="flex items-center justify-between text-xs text-slate-500">
<span>{{ run.items.length }} items</span>
<span>{{ runProgressPercent(run) }}%</span>
</div>
<div class="mt-2 w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
class="h-full bg-cyan-500/80 transition-all duration-200"
:style="{ width: runProgressPercent(run) + '%' }"
/>
</div>
</div>
<div class="mt-3 max-h-48 overflow-auto space-y-2">
<div
v-for="item in run.items"
:key="item.id"
class="flex items-start gap-2 rounded-xl border border-slate-800 bg-slate-950/10 px-3 py-2"
>
<Loader2 v-if="item.status === 'running'" class="w-4 h-4 mt-0.5 text-cyan-300 animate-spin" aria-hidden="true" />
<CheckCircle2 v-else-if="item.status === 'success'" class="w-4 h-4 mt-0.5 text-emerald-300" aria-hidden="true" />
<AlertTriangle v-else-if="item.status === 'error'" class="w-4 h-4 mt-0.5 text-red-300" aria-hidden="true" />
<XCircle v-else-if="item.status === 'cancelled'" class="w-4 h-4 mt-0.5 text-slate-300" aria-hidden="true" />
<span v-else class="w-4 h-4 mt-0.5 rounded-full bg-amber-400/30" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="text-xs text-slate-200 truncate">{{ item.label }}</p>
<p v-if="item.status === 'running' && item.progress != null" class="mt-1 text-[11px] text-slate-500">
{{ item.progress }}%
</p>
<p v-if="item.status === 'error' && item.message" class="mt-1 text-[11px] text-red-300 break-words">
{{ item.message }}
</p>
</div>
</div>
</div>
</div>
</div>
</aside>
</div>
<SftpFilePickerModal
:open="pickerOpen"
:connection-id="remoteSourceConnectionId"
:multi="pickerMulti"
@close="pickerOpen = false"
@select="onPickerSelect"
/>
</div>
</template>
```
- [ ] **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<Map<String, Object>> 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<String, Object> err = new HashMap<>();
err.put("error", "sourcePaths is required");
return ResponseEntity.badRequest().body(err);
}
if (sourceConnectionId == null) {
Map<String, Object> err = new HashMap<>();
err.put("error", "sourceConnectionId is required");
return ResponseEntity.badRequest().body(err);
}
if (targetConnectionId == null) {
Map<String, Object> err = new HashMap<>();
err.put("error", "targetConnectionId is required");
return ResponseEntity.badRequest().body(err);
}
if (targetDirOrPath == null || targetDirOrPath.trim().isEmpty()) {
Map<String, Object> err = new HashMap<>();
err.put("error", "targetDirOrPath is required");
return ResponseEntity.badRequest().body(err);
}
List<Map<String, Object>> taskResponses = new ArrayList<>();
for (String sourcePath : sourcePaths) {
ResponseEntity<Map<String, String>> validation = validateTransferPaths(sourcePath, targetDirOrPath);
if (validation != null) {
Map<String, Object> 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<String, Object> 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<Map<String, Object>> createTransferRemoteTask(
@RequestParam Long sourceConnectionId,
@RequestParam String sourcePath,
@RequestParam Long targetConnectionId,
@RequestParam String targetPath,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
ResponseEntity<Map<String, String>> validation = validateTransferPaths(sourcePath, targetPath);
if (validation != null) {
Map<String, Object> 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 <token>" \
-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<void>)[] = 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<void> => 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 构建成功,服务启动正常
- [ ] 端到端测试通过(单文件兼容性、多文件传输、并发控制、错误处理、取消任务、登录流程)
- [ ] 功能文档已创建
- [ ] 代码已提交