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

53 KiB
Raw Blame History

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.vuetransfers.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: stringremoteSourcePaths: 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 构建成功,服务启动正常
  • 端到端测试通过(单文件兼容性、多文件传输、并发控制、错误处理、取消任务、登录流程)
  • 功能文档已创建
  • 代码已提交