53 KiB
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 支持多选模式
<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 支持多文件源路径
<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: 运行前端类型检查
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() 支持多源路径
@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() 保持单文件兼容性
@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: 运行后端编译测试
cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend && mvn clean compile
Expected: Compilation succeeds with no errors.
- Step 4: 启动后端并验证 API
cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend && mvn spring-boot:run
- 验证
/api/sftp/transfer-remote/tasks接口(单文件)仍正常工作 - 验证
/api/sftp/transfer-remote/batch-tasks接口存在,可通过 curl 验证:
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
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
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 中添加新方法
return {
runs,
recentRuns,
controllers,
clearRuns,
cancelRun,
startLocalToMany,
startRemoteToMany,
startRemoteToManyMulti,
}
- Step 4: 运行前端类型检查
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: 清空数据库并重启后端
cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend
rm -rf data/sshmanager
mvn spring-boot:run
- Step 2: 启动前端
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: 创建功能文档
# 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: 运行最终构建验证
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: 查看日志
docker compose -f docker/docker-compose.yml logs -f
Verify: No stack traces or critical errors in application logs.
- Step 4: 提交代码
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 构建成功,服务启动正常
- 端到端测试通过(单文件兼容性、多文件传输、并发控制、错误处理、取消任务、登录流程)
- 功能文档已创建
- 代码已提交