feat: 增强 Transfers 页面文件浏览功能

- 在 SftpFilePickerModal 中添加搜索功能
- 添加显示/隐藏文件切换按钮(参考 SftpView)
- Remote->Many 模式下目标连接列表自动排除源连接
- 全选功能自动排除源连接
- 添加空状态提示信息
- 优化用户体验和交互逻辑
This commit is contained in:
liumangmang
2026-03-12 17:45:07 +08:00
parent 085123697e
commit 80fc5c8a0f
18 changed files with 2298 additions and 294 deletions

View File

@@ -60,11 +60,11 @@ watch([searchQuery, showHiddenFiles, files], () => {
onBeforeUnmount(() => {
clearTimeout(searchDebounceTimer)
stopTransferProgress()
})
const showUploadProgress = ref(false)
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
const lastUpdate = ref(0)
const totalProgress = computed(() => {
if (uploadProgressList.value.length === 0) return 0
@@ -92,11 +92,77 @@ function formatDate(ts: number): string {
}
const showTransferModal = ref(false)
const transferFile = ref<SftpFileInfo | null>(null)
const transferTargetConnectionId = ref<number | null>(null)
const transferTargetPath = ref('')
const transferring = ref(false)
const transferError = ref('')
const transferFile = ref<SftpFileInfo | null>(null)
const transferTargetConnectionId = ref<number | null>(null)
const transferTargetPath = ref('')
const transferring = ref(false)
const transferError = ref('')
const transferProgress = ref(0)
const transferTransferredBytes = ref(0)
const transferTotalBytes = ref(0)
const transferTaskId = ref('')
let transferPollAbort = false
function resetTransferProgress() {
transferProgress.value = 0
transferTransferredBytes.value = 0
transferTotalBytes.value = 0
transferTaskId.value = ''
}
function stopTransferProgress() {
transferPollAbort = true
}
function formatTransferBytes(bytes: number) {
return formatSize(Math.max(0, bytes || 0))
}
async function waitForTransferTask(taskId: string) {
transferPollAbort = false
transferTaskId.value = taskId
while (!transferPollAbort) {
const res = await sftpApi.getRemoteTransferTask(taskId)
const task = res.data
transferProgress.value = Math.max(0, Math.min(100, task.progress || 0))
transferTransferredBytes.value = Math.max(0, task.transferredBytes || 0)
transferTotalBytes.value = Math.max(0, task.totalBytes || 0)
if (task.status === 'success') return task
if (task.status === 'error') throw new Error(task.error || '传输失败')
if (task.status === 'cancelled') throw new Error('传输已取消')
await new Promise((resolve) => setTimeout(resolve, 300))
}
throw new Error('传输已取消')
}
async function cancelTransfer() {
const taskId = transferTaskId.value
stopTransferProgress()
if (!taskId) {
transferring.value = false
transferError.value = '传输已取消'
return
}
try {
const res = await sftpApi.cancelRemoteTransferTask(taskId)
const task = res.data
transferProgress.value = Math.max(0, Math.min(100, task.progress || transferProgress.value))
transferTransferredBytes.value = Math.max(0, task.transferredBytes || transferTransferredBytes.value)
transferTotalBytes.value = Math.max(0, task.totalBytes || transferTotalBytes.value)
if (task.cancelRequested) {
transferError.value = '已请求取消传输'
} else {
transferError.value = task.message || '当前传输正在收尾,稍后会结束'
}
} catch (err: unknown) {
const res = err as { response?: { data?: { error?: string } } }
transferError.value = res?.response?.data?.error ?? '取消传输失败'
}
}
onMounted(() => {
conn.value = store.getConnection(connectionId.value)
@@ -208,7 +274,7 @@ async function handleFileSelect(e: Event) {
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
const uploadTasks: { id: string; file: File }[] = []
const uploadTasks: { id: string; file: File; taskId?: string }[] = []
for (let i = 0; i < selected.length; i++) {
const file = selected[i]
if (!file) continue
@@ -227,51 +293,56 @@ async function handleFileSelect(e: Event) {
showUploadProgress.value = true
const MAX_PARALLEL = 5
const results: Promise<void>[] = []
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
const batchPromises = batch.map(task => {
if (!task) return Promise.resolve()
const batchPromises = batch.map(async task => {
if (!task) return
const { id, file } = task
const item = uploadProgressList.value.find(item => item.id === id)
if (!item) return Promise.resolve()
if (!item) return
item.status = 'uploading'
return new Promise<void>((resolve, reject) => {
const onProgress = (percent: number) => {
const now = Date.now()
if (now - (lastUpdate.value || 0) > 100) {
item.uploaded = Math.round((file.size * percent) / 100)
item.total = file.size
lastUpdate.value = now
try {
// Start upload and get taskId
const uploadRes = await sftpApi.uploadFile(connectionId.value, path, file)
const taskId = uploadRes.data.taskId
// Poll for progress
while (true) {
const statusRes = await sftpApi.getUploadTask(taskId)
const taskStatus = statusRes.data
item.uploaded = taskStatus.transferredBytes
item.total = taskStatus.totalBytes
if (taskStatus.status === 'success') {
item.status = 'success'
break
}
if (taskStatus.status === 'error') {
item.status = 'error'
item.message = taskStatus.error || 'Upload failed'
break
}
await new Promise(resolve => setTimeout(resolve, 200))
}
const xhr = sftpApi.uploadFileWithProgress(connectionId.value, path, file)
xhr.onProgress = onProgress
xhr.onload = () => {
item.status = 'success'
resolve()
}
xhr.onerror = () => {
item.status = 'error'
item.message = 'Network error'
reject(new Error('Network error'))
}
})
} catch (err: any) {
item.status = 'error'
item.message = err?.response?.data?.error || 'Upload failed'
}
})
results.push(...batchPromises)
await Promise.allSettled(batchPromises)
}
await Promise.allSettled(results)
await loadPath()
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
showUploadProgress.value = false
uploadProgressList.value = []
uploading.value = false
fileInputRef.value!.value = ''
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
toast.success(`成功上传 ${successCount} 个文件`)
}
@@ -310,13 +381,16 @@ async function openTransferModal(file: SftpFileInfo) {
showTransferModal.value = true
}
function closeTransferModal() {
showTransferModal.value = false
transferFile.value = null
transferTargetConnectionId.value = null
transferTargetPath.value = ''
transferError.value = ''
}
function closeTransferModal() {
if (transferring.value) return
stopTransferProgress()
showTransferModal.value = false
transferFile.value = null
transferTargetConnectionId.value = null
transferTargetPath.value = ''
transferError.value = ''
resetTransferProgress()
}
async function submitTransfer() {
const file = transferFile.value
@@ -324,21 +398,25 @@ async function submitTransfer() {
if (!file || targetId == null) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const sourcePath = base ? base + '/' + file.name : file.name
let targetPath = transferTargetPath.value.trim()
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
transferring.value = true
transferError.value = ''
try {
await sftpApi.transferRemote(connectionId.value, sourcePath, targetId, targetPath)
loadPath()
closeTransferModal()
} catch (err: unknown) {
const res = err as { response?: { data?: { error?: string } } }
transferError.value = res?.response?.data?.error ?? '传输失败'
} finally {
transferring.value = false
}
}
let targetPath = transferTargetPath.value.trim()
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
transferring.value = true
transferError.value = ''
resetTransferProgress()
try {
const created = await sftpApi.createRemoteTransferTask(connectionId.value, sourcePath, targetId, targetPath)
await waitForTransferTask(created.data.taskId)
transferProgress.value = 100
await loadPath()
closeTransferModal()
} catch (err: unknown) {
const res = err as { response?: { data?: { error?: string } } }
transferError.value = res?.response?.data?.error ?? (err as Error)?.message ?? '传输失败'
} finally {
stopTransferProgress()
transferring.value = false
}
}
</script>
<template>
@@ -568,17 +646,32 @@ async function submitTransfer() {
placeholder="/"
/>
</div>
</div>
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
@click="closeTransferModal"
:disabled="transferring"
class="rounded-lg border border-slate-600 px-4 py-2 text-slate-300 hover:bg-slate-700 disabled:opacity-50 cursor-pointer"
>
取消
</button>
</div>
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
<div v-if="transferring" class="mt-3 space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400">
<span>传输进度</span>
<span>{{ transferProgress }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-700">
<div
class="h-full bg-cyan-500 transition-all duration-300"
:style="{ width: transferProgress + '%' }"
></div>
</div>
<div class="flex items-center justify-between text-[11px] text-slate-500">
<span>{{ formatTransferBytes(transferTransferredBytes) }}</span>
<span>{{ transferTotalBytes > 0 ? formatTransferBytes(transferTotalBytes) : '--' }}</span>
</div>
</div>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
@click="transferring ? cancelTransfer() : closeTransferModal()"
class="rounded-lg border border-slate-600 px-4 py-2 text-slate-300 hover:bg-slate-700 cursor-pointer"
>
{{ transferring ? '取消传输' : '取消' }}
</button>
<button
type="button"
@click="submitTransfer"