feat: 增强 Transfers 页面文件浏览功能
- 在 SftpFilePickerModal 中添加搜索功能 - 添加显示/隐藏文件切换按钮(参考 SftpView) - Remote->Many 模式下目标连接列表自动排除源连接 - 全选功能自动排除源连接 - 添加空状态提示信息 - 优化用户体验和交互逻辑
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user