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