feat: Remote->Many 支持多源文件传输
支持多选源文件并按源文件×目标连接生成任务队列;增加 Directory/Exact Path 目标模式与重名覆盖提示,同时修复传输/上传 SSE 订阅未及时关闭导致的连接堆积。
This commit is contained in:
@@ -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<string[]>([])
|
||||
const currentPath = ref('/')
|
||||
const pathParts = computed(() => (currentPath.value === '/' ? [] : currentPath.value.split('/').filter(Boolean)))
|
||||
const files = ref<SftpFileInfo[]>([])
|
||||
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<SftpFileInfo[]>([])
|
||||
|
||||
const selectedPaths = ref<string[]>([])
|
||||
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(() => {
|
||||
<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">选择源文件</h3>
|
||||
<p class="text-xs text-slate-400 truncate">双击文件不需要,单击即选择</p>
|
||||
<p v-if="!props.multiple" class="text-xs text-slate-400 truncate">双击文件不需要,单击即选择</p>
|
||||
<p v-else class="text-xs text-slate-400 truncate">单击文件切换选择;目录仅用于导航</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@@ -141,6 +225,7 @@ onBeforeUnmount(() => {
|
||||
: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="刷新"
|
||||
type="button"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||||
@@ -151,6 +236,7 @@ onBeforeUnmount(() => {
|
||||
@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="关闭"
|
||||
type="button"
|
||||
>
|
||||
<X class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -162,6 +248,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
@click="navigateToIndex(-1)"
|
||||
class="px-2 py-1 rounded hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer truncate"
|
||||
type="button"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
@@ -170,6 +257,7 @@ onBeforeUnmount(() => {
|
||||
<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]"
|
||||
type="button"
|
||||
>
|
||||
{{ part || '/' }}
|
||||
</button>
|
||||
@@ -179,6 +267,8 @@ onBeforeUnmount(() => {
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
name="sftp-file-search"
|
||||
autocomplete="off"
|
||||
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="搜索文件"
|
||||
@@ -188,21 +278,71 @@ 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"
|
||||
>
|
||||
<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 v-if="props.multiple" class="mt-3 rounded-xl border border-slate-700 bg-slate-900/30 px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm text-slate-200">Selected ({{ selectedCount }})</div>
|
||||
<button
|
||||
v-if="selectedCount > 0"
|
||||
@click="clearSelected"
|
||||
class="text-xs text-slate-400 hover:text-slate-100 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selectedCount === 0" class="mt-1 text-xs text-slate-500">未选择任何文件</div>
|
||||
<div v-else class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="path in selectedPaths"
|
||||
:key="path"
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-slate-700 bg-slate-800/40 px-2 py-1 text-xs text-slate-200"
|
||||
:title="path"
|
||||
>
|
||||
<span class="max-w-[220px] truncate">{{ displayPathName(path) }}</span>
|
||||
<button
|
||||
class="w-6 h-6 grid place-items-center rounded hover:bg-slate-700/60 text-slate-300 hover:text-slate-100 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
:aria-label="'移除 ' + displayPathName(path)"
|
||||
@click="removeSelected(path)"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-auto divide-y divide-slate-800">
|
||||
<button
|
||||
<div
|
||||
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]"
|
||||
:aria-label="file.directory ? '打开目录' : '选择文件'"
|
||||
@keydown="handleRowKeyDown($event, 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] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/80"
|
||||
:role="props.multiple && !file.directory ? 'checkbox' : 'button'"
|
||||
:tabindex="0"
|
||||
:aria-checked="props.multiple && !file.directory ? isSelected(filePath(file)) : undefined"
|
||||
:aria-label="file.directory
|
||||
? '打开目录 ' + file.name
|
||||
: (props.multiple
|
||||
? ((isSelected(filePath(file)) ? '取消选择文件 ' : '选择文件 ') + file.name)
|
||||
: ('选择文件 ' + file.name))"
|
||||
>
|
||||
<span v-if="props.multiple && !file.directory" class="flex-shrink-0" aria-hidden="true">
|
||||
<span
|
||||
class="h-4 w-4 rounded border grid place-items-center transition-colors"
|
||||
:class="isSelected(filePath(file)) ? 'border-cyan-400 bg-cyan-500' : 'border-slate-500 bg-slate-900/40'"
|
||||
>
|
||||
<Check v-if="isSelected(filePath(file))" class="w-3 h-3 text-slate-950" aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
<component
|
||||
:is="file.directory ? FolderOpen : File"
|
||||
class="w-5 h-5 flex-shrink-0"
|
||||
@@ -211,12 +351,30 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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 v-if="props.multiple" class="flex items-center justify-end gap-2 px-4 py-3 border-t border-slate-700 bg-slate-900/40">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="min-h-[44px] px-4 rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmMany"
|
||||
:disabled="selectedCount === 0"
|
||||
class="min-h-[44px] px-4 rounded-lg bg-cyan-600/90 text-slate-950 hover:bg-cyan-500 disabled:opacity-50 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
>
|
||||
Confirm ({{ selectedCount }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
Reference in New Issue
Block a user