From aced2871b229c64f60cc764b1990445b9e3e8f57 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Tue, 24 Mar 2026 10:54:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Remote->Many=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E6=BA=90=E6=96=87=E4=BB=B6=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持多选源文件并按源文件×目标连接生成任务队列;增加 Directory/Exact Path 目标模式与重名覆盖提示,同时修复传输/上传 SSE 订阅未及时关闭导致的连接堆积。 --- .../src/components/SftpFilePickerModal.vue | 208 +++++++++++++-- frontend/src/stores/transfers.ts | 231 +++++++++++------ frontend/src/views/TransfersView.vue | 242 +++++++++++++++--- 3 files changed, 552 insertions(+), 129 deletions(-) diff --git a/frontend/src/components/SftpFilePickerModal.vue b/frontend/src/components/SftpFilePickerModal.vue index da486da..8f9f0cc 100644 --- a/frontend/src/components/SftpFilePickerModal.vue +++ b/frontend/src/components/SftpFilePickerModal.vue @@ -3,16 +3,19 @@ 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 } from 'lucide-vue-next' +import { X, FolderOpen, File, ChevronRight, RefreshCw, Eye, EyeOff, Check } from 'lucide-vue-next' -const props = defineProps<{ open: boolean; connectionId: number | null }>() +const props = withDefaults(defineProps<{ open: boolean; connectionId: number | null; multiple?: boolean }>(), { + multiple: false +}) const emit = defineEmits<{ (e: 'close'): void (e: 'select', path: string): void + (e: 'select-many', paths: string[]): void }>() -const currentPath = ref('.') -const pathParts = ref([]) +const currentPath = ref('/') +const pathParts = computed(() => (currentPath.value === '/' ? [] : currentPath.value.split('/').filter(Boolean))) const files = ref([]) const loading = ref(false) const error = ref('') @@ -20,34 +23,62 @@ const error = ref('') const showHiddenFiles = ref(false) const searchQuery = ref('') let searchDebounceTimer = 0 +let suppressNextSearchDebounce = false const filteredFiles = ref([]) +const selectedPaths = ref([]) +const selectedPathSet = computed(() => new Set(selectedPaths.value)) +const selectedCount = computed(() => selectedPaths.value.length) + 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('.')) + const sanitized = files.value.filter((f) => f.name !== '.' && f.name !== '..') + const base = showHiddenFiles.value ? sanitized : sanitized.filter((f) => !f.name.startsWith('.')) filteredFiles.value = q ? base.filter((f) => f.name.toLowerCase().includes(q)) : base } -watch([searchQuery, showHiddenFiles, files], () => { +watch(searchQuery, () => { + if (suppressNextSearchDebounce) { + suppressNextSearchDebounce = false + clearTimeout(searchDebounceTimer) + searchDebounceTimer = 0 + return + } clearTimeout(searchDebounceTimer) searchDebounceTimer = window.setTimeout(() => { applyFileFilters() }, 300) +}) + +watch([files, showHiddenFiles], () => { + applyFileFilters() }, { immediate: true }) +function normalizeServerPath(input: string) { + const raw = (input || '').trim() + if (!raw || raw === '.') return '/' + if (raw === '/') return '/' + const abs = raw.startsWith('/') ? raw : '/' + raw + return abs.replace(/\/+$/, '') || '/' +} + +function joinAbsolutePath(base: string, name: string) { + const safeBase = !base || base === '.' ? '/' : base + const baseClean = safeBase === '/' ? '' : safeBase.replace(/\/+$/, '') + const nameClean = (name || '').replace(/^\/+/, '') + return (baseClean ? baseClean + '/' : '/') + nameClean +} + 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) + currentPath.value = normalizeServerPath(res.data.path || '/') } catch (e: unknown) { - currentPath.value = '.' - pathParts.value = [] + currentPath.value = '/' } } @@ -55,10 +86,15 @@ async function load() { if (!canInteract.value || props.connectionId == null) return loading.value = true error.value = '' + clearTimeout(searchDebounceTimer) + searchDebounceTimer = 0 + suppressNextSearchDebounce = true searchQuery.value = '' + applyFileFilters() try { const res = await sftpApi.listFiles(props.connectionId, currentPath.value) files.value = res.data + .filter((f) => f.name !== '.' && f.name !== '..') .slice() .sort((a, b) => { if (a.directory !== b.directory) return a.directory ? -1 : 1 @@ -74,26 +110,49 @@ async function load() { 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) + currentPath.value = joinAbsolutePath(currentPath.value, name) load() } function navigateToIndex(i: number) { if (loading.value) return if (i < 0) { - currentPath.value = '.' + currentPath.value = '/' } else { - currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/' + const next = pathParts.value.slice(0, i + 1).join('/') + currentPath.value = next ? '/' + next : '/' } - 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 + return joinAbsolutePath(currentPath.value, file.name) +} + +function isSelected(path: string) { + return selectedPathSet.value.has(path) +} + +function toggleSelected(path: string) { + if (isSelected(path)) { + selectedPaths.value = selectedPaths.value.filter((p) => p !== path) + return + } + selectedPaths.value = [...selectedPaths.value, path] +} + +function removeSelected(path: string) { + if (!isSelected(path)) return + selectedPaths.value = selectedPaths.value.filter((p) => p !== path) +} + +function clearSelected() { + selectedPaths.value = [] +} + +function displayPathName(path: string) { + const parts = path.split('/').filter(Boolean) + return parts[parts.length - 1] || path } function handleClick(file: SftpFileInfo) { @@ -101,7 +160,30 @@ function handleClick(file: SftpFileInfo) { navigateToDir(file.name) return } - emit('select', filePath(file)) + + const path = filePath(file) + if (props.multiple) { + toggleSelected(path) + return + } + + emit('select', path) + emit('close') +} + +function handleRowKeyDown(e: KeyboardEvent, file: SftpFileInfo) { + const isEnter = e.key === 'Enter' + const isSpace = e.key === ' ' || e.code === 'Space' + if (!isEnter && !isSpace) return + e.preventDefault() + if (isSpace && e.repeat) return + handleClick(file) +} + +function confirmMany() { + if (!props.multiple) return + if (selectedPaths.value.length === 0) return + emit('select-many', selectedPaths.value) emit('close') } @@ -109,6 +191,7 @@ watch( () => [props.open, props.connectionId] as const, async ([open]) => { if (!open) return + selectedPaths.value = [] await initPath() await load() } @@ -133,7 +216,8 @@ onBeforeUnmount(() => {

选择源文件

-

双击文件不需要,单击即选择

+

双击文件不需要,单击即选择

+

单击文件切换选择;目录仅用于导航

@@ -162,6 +248,7 @@ onBeforeUnmount(() => { @@ -170,6 +257,7 @@ onBeforeUnmount(() => { @@ -179,6 +267,8 @@ onBeforeUnmount(() => { { 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 ? '隐藏隐藏文件' : '显示隐藏文件'" + type="button" >

{{ error }}

+ +
+
+
Selected ({{ selectedCount }})
+ +
+
未选择任何文件
+
+ + {{ displayPathName(path) }} + + +
+
- +
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏文件)') }}
+ +
+ + +
diff --git a/frontend/src/stores/transfers.ts b/frontend/src/stores/transfers.ts index 081cabc..5f53959 100644 --- a/frontend/src/stores/transfers.ts +++ b/frontend/src/stores/transfers.ts @@ -73,9 +73,27 @@ async function runWithConcurrency( await Promise.allSettled(workers) } -async function waitForRemoteTransfer(taskId: string, onProgress: (progress: number) => void, unsubscribers: (() => void)[]) { +async function waitForRemoteTransfer( + taskId: string, + onProgress: (progress: number) => void, + unsubscribers: (() => void)[] +) { return new Promise((resolve, reject) => { console.log('[waitForRemoteTransfer] Subscribing to task:', taskId) + let done = false + + const finish = (err?: Error) => { + if (done) return + done = true + try { + unsubscribe() + } catch { + // ignore + } + if (err) reject(err) + else resolve() + } + const unsubscribe = subscribeRemoteTransferProgress(taskId, (task) => { const progress = Math.max(0, Math.min(100, task.progress || 0)) console.log('[waitForRemoteTransfer] Progress from SSE:', progress, 'status:', task.status) @@ -83,25 +101,51 @@ async function waitForRemoteTransfer(taskId: string, onProgress: (progress: numb if (task.status === 'success') { console.log('[waitForRemoteTransfer] Task succeeded:', taskId) - resolve() + finish() } else if (task.status === 'error') { console.error('[waitForRemoteTransfer] Task errored:', taskId, task.error) - reject(new Error(task.error || 'Transfer failed')) + finish(new Error(task.error || 'Transfer failed')) } else if (task.status === 'cancelled') { console.log('[waitForRemoteTransfer] Task cancelled:', taskId) - reject(new Error('Cancelled')) + finish(new Error('Cancelled')) } }) - unsubscribers.push(unsubscribe) + // cancelRun/clearRuns will call this; it must close SSE and release the waiter. + unsubscribers.push(() => { + finish(new Error('Cancelled')) + }) }) } -function buildRemoteTransferPath(targetDir: string, filename: string) { - let targetPath = targetDir.trim() - if (!targetPath) targetPath = '/' - if (!targetPath.endsWith('/')) targetPath = targetPath + '/' - return targetPath + filename +function basename(p: string) { + return p.split('/').filter(Boolean).pop() || p +} + +function normalizeDir(p: string) { + let dir = p.trim() + if (!dir) dir = '/' + if (!dir.endsWith('/')) dir = dir + '/' + return dir +} + +function resolveRemoteTargetPath(params: { + targetMode: 'dir' | 'path' + targetDirOrPath: string + sourcePath: string + sourceCount: number +}) { + if (params.targetMode === 'path') { + if (params.sourceCount !== 1) { + throw new Error('Exact Path mode requires a single source file') + } + const t = params.targetDirOrPath.trim() + if (!t) throw new Error('target path is required') + return t + } + + const dir = normalizeDir(params.targetDirOrPath) + return dir + basename(params.sourcePath) } export const useTransfersStore = defineStore('transfers', () => { @@ -219,18 +263,35 @@ export const useTransfersStore = defineStore('transfers', () => { // 订阅上传任务进度,等待真正完成 await new Promise((resolve, reject) => { + let done = false + const finish = (err?: Error) => { + if (done) return + done = true + try { + unsubscribe() + } catch { + // ignore + } + if (err) reject(err) + else resolve() + } + const unsubscribe = subscribeUploadProgress(taskId, (task) => { const progress = Math.max(0, Math.min(100, task.progress || 0)) item.progress = progress runs.value = [...runs.value] if (task.status === 'success') { - resolve() + finish() } else if (task.status === 'error') { - reject(new Error(task.error || 'Upload failed')) + finish(new Error(task.error || 'Upload failed')) } }) - unsubscribers.push(unsubscribe) + + // cancelRun/clearRuns will call this; close SSE + release waiter. + unsubscribers.push(() => { + finish(new Error('Cancelled')) + }) }) item.status = 'success' @@ -263,29 +324,33 @@ export const useTransfersStore = defineStore('transfers', () => { async function startRemoteToMany(params: { sourceConnectionId: number - sourcePath: string + sourcePaths: string[] targetConnectionIds: number[] targetDirOrPath: string + targetMode: 'dir' | 'path' concurrency?: number }) { - const { sourceConnectionId, sourcePath, targetConnectionIds, targetDirOrPath } = params + const { sourceConnectionId, sourcePaths, targetConnectionIds, targetDirOrPath, targetMode } = params const concurrency = params.concurrency ?? 3 if (sourceConnectionId == null) return + const sources = sourcePaths.map((p) => p.trim()).filter(Boolean) + if (sources.length === 0) return + const runId = uid('run') - const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath - const runItems: TransferItem[] = targetConnectionIds.map((targetId) => ({ - id: uid('item'), - label: `#${sourceConnectionId}:${sourcePath} -> #${targetId}:${targetDirOrPath}`, - status: 'queued' as const, - progress: 0, - })) + const onlySource = sources[0] + const title = sources.length === 1 && onlySource + ? `Remote ${basename(onlySource)} -> ${targetConnectionIds.length} targets` + : `Remote ${sources.length} files -> ${targetConnectionIds.length} targets` + + const runItems: TransferItem[] = [] + const tasks: (() => Promise)[] = [] const run: TransferRun = { id: runId, mode: 'REMOTE_TO_MANY' as const, - title: `Remote ${filename} -> ${targetConnectionIds.length} targets`, + title, createdAt: now(), items: runItems, status: 'queued' as const, @@ -302,66 +367,82 @@ export const useTransfersStore = defineStore('transfers', () => { unsubscribers, }) - const tasks = runItems.map((item, index) => { - return async () => { - const targetId = targetConnectionIds[index] - if (targetId == null) { - item.status = 'error' - item.progress = 100 - item.message = 'Missing target connection' - item.finishedAt = now() - runs.value = [...runs.value] - return + for (const sourcePath of sources) { + for (const targetId of targetConnectionIds) { + const item: TransferItem = { + id: uid('item'), + label: `#${sourceConnectionId}:${sourcePath} -> #${targetId}:${targetDirOrPath}`, + status: 'queued' as const, + progress: 0, } - if (cancelled) { - item.status = 'cancelled' - item.progress = 100 - item.finishedAt = now() - runs.value = [...runs.value] - return - } - item.status = 'running' - item.progress = 0 - item.startedAt = now() - runs.value = [...runs.value] - console.log('[Remote->Many] Starting transfer:', item.label, 'targetId:', targetId) - try { - const targetPath = buildRemoteTransferPath(targetDirOrPath, filename) - console.log('[Remote->Many] Target path:', targetPath) + runItems.push(item) - const task = await createRemoteTransferTask(sourceConnectionId, sourcePath, targetId, targetPath) - const taskId = task.data.taskId - console.log('[Remote->Many] Task created:', taskId) - await waitForRemoteTransfer(taskId, (progress) => { - console.log('[Remote->Many] 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] 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] Transfer failed:', item.label, 'error:', msg) - if (msg === 'Cancelled') { + tasks.push(async () => { + if (cancelled) { item.status = 'cancelled' item.progress = 100 - } else { + item.finishedAt = now() + runs.value = [...runs.value] + return + } + + item.status = 'running' + item.progress = 0 + item.startedAt = now() + runs.value = [...runs.value] + console.log('[Remote->Many] Starting transfer:', item.label, 'targetId:', targetId) + + let targetPath = '' + try { + targetPath = resolveRemoteTargetPath({ + targetMode, + targetDirOrPath, + sourcePath, + sourceCount: sources.length, + }) + } catch (e: unknown) { item.status = 'error' item.progress = 100 - item.message = msg + item.message = (e as Error)?.message || 'Invalid target path' + item.finishedAt = now() + runs.value = [...runs.value] + return } - item.finishedAt = now() - runs.value = [...runs.value] - } finally { - runs.value = [...runs.value] - } + + console.log('[Remote->Many] Target path:', targetPath) + + try { + const task = await createRemoteTransferTask(sourceConnectionId, sourcePath, targetId, targetPath) + const taskId = task.data.taskId + console.log('[Remote->Many] Task created:', taskId) + await waitForRemoteTransfer(taskId, (progress) => { + item.progress = Math.max(item.progress || 0, progress) + runs.value = [...runs.value] + }, unsubscribers) + + item.status = 'success' + item.progress = 100 + item.finishedAt = now() + 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' + 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] + } + }) } - }) + } await runWithConcurrency(tasks, concurrency) runs.value = [...runs.value] diff --git a/frontend/src/views/TransfersView.vue b/frontend/src/views/TransfersView.vue index d784fa5..a06baaf 100644 --- a/frontend/src/views/TransfersView.vue +++ b/frontend/src/views/TransfersView.vue @@ -1,5 +1,5 @@