844 lines
30 KiB
Vue
844 lines
30 KiB
Vue
<script setup lang="ts">
|
||
defineOptions({ name: 'SftpView' })
|
||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useToast } from 'vue-toast-notification'
|
||
import { useConnectionsStore } from '../stores/connections'
|
||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||
import * as sftpApi from '../api/sftp'
|
||
import type { SftpFileInfo } from '../api/sftp'
|
||
import {
|
||
ArrowLeft,
|
||
FolderOpen,
|
||
File,
|
||
Upload,
|
||
FolderPlus,
|
||
RefreshCw,
|
||
Eye,
|
||
EyeOff,
|
||
Download,
|
||
Trash2,
|
||
ChevronRight,
|
||
Copy,
|
||
CheckCircle,
|
||
AlertCircle,
|
||
Loader,
|
||
} from 'lucide-vue-next'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const toast = useToast()
|
||
const store = useConnectionsStore()
|
||
const sftpTabsStore = useSftpTabsStore()
|
||
|
||
const connectionId = computed(() => Number(route.params.id))
|
||
const conn = ref(store.getConnection(connectionId.value))
|
||
|
||
const currentPath = ref('.')
|
||
const pathParts = ref<string[]>([])
|
||
const files = ref<SftpFileInfo[]>([])
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
const uploading = ref(false)
|
||
const selectedFile = ref<string | null>(null)
|
||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||
|
||
const showHiddenFiles = ref(false)
|
||
const searchQuery = ref('')
|
||
let searchDebounceTimer = 0
|
||
const filteredFiles = ref<SftpFileInfo[]>([])
|
||
|
||
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 })
|
||
|
||
onBeforeUnmount(() => {
|
||
invalidateUploadContext()
|
||
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 }[]>([])
|
||
let activeUploadContextId = 0
|
||
|
||
function createUploadContext() {
|
||
activeUploadContextId += 1
|
||
return activeUploadContextId
|
||
}
|
||
|
||
function invalidateUploadContext() {
|
||
activeUploadContextId += 1
|
||
}
|
||
|
||
const totalProgress = computed(() => {
|
||
if (uploadProgressList.value.length === 0) return 0
|
||
const totalSize = uploadProgressList.value.reduce((sum, item) => sum + item.size, 0)
|
||
const uploadedSize = uploadProgressList.value.reduce((sum, item) => {
|
||
if (item.status === 'success') return sum + item.size
|
||
if (item.status === 'uploading') return sum + item.uploaded
|
||
return sum
|
||
}, 0)
|
||
return totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
|
||
})
|
||
|
||
const currentUploadingFile = computed(() => {
|
||
return uploadProgressList.value.find(item => item.status === 'uploading')?.name || ''
|
||
})
|
||
|
||
function formatSize(bytes: number): string {
|
||
if (bytes < 1024) return bytes + ' B'
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||
}
|
||
|
||
function formatDate(ts: number): string {
|
||
return new Date(ts).toLocaleString()
|
||
}
|
||
|
||
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 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 ?? '取消传输失败'
|
||
}
|
||
}
|
||
|
||
let routeInitRequestId = 0
|
||
|
||
function isStaleRouteInit(requestId?: number, isCancelled?: () => boolean) {
|
||
return (isCancelled?.() ?? false) || (requestId != null && requestId !== routeInitRequestId)
|
||
}
|
||
|
||
function resetVolatileSftpState() {
|
||
invalidateUploadContext()
|
||
conn.value = undefined
|
||
currentPath.value = '.'
|
||
pathParts.value = []
|
||
files.value = []
|
||
filteredFiles.value = []
|
||
loading.value = false
|
||
error.value = ''
|
||
selectedFile.value = null
|
||
searchQuery.value = ''
|
||
|
||
showUploadProgress.value = false
|
||
uploadProgressList.value = []
|
||
uploading.value = false
|
||
|
||
stopTransferProgress()
|
||
showTransferModal.value = false
|
||
transferFile.value = null
|
||
transferTargetConnectionId.value = null
|
||
transferTargetPath.value = ''
|
||
transferError.value = ''
|
||
transferring.value = false
|
||
resetTransferProgress()
|
||
}
|
||
|
||
watch(
|
||
() => route.params.id,
|
||
async (routeId, _oldRouteId, onCleanup) => {
|
||
const requestId = ++routeInitRequestId
|
||
let cleanedUp = false
|
||
onCleanup(() => {
|
||
cleanedUp = true
|
||
})
|
||
|
||
const isRouteInitCancelled = () => cleanedUp
|
||
|
||
const rawId = Array.isArray(routeId) ? routeId[0] : routeId
|
||
const parsedId = Number(rawId)
|
||
|
||
// 只在切换到不同连接时重置状态
|
||
// 使用 store 中的状态跟踪,确保在组件重建后仍能正确判断
|
||
const lastLoaded = sftpTabsStore.getLastLoadedConnectionId(parsedId)
|
||
const isConnectionChanged = lastLoaded !== parsedId
|
||
if (isConnectionChanged) {
|
||
resetVolatileSftpState()
|
||
}
|
||
|
||
if (!rawId || !Number.isInteger(parsedId) || parsedId <= 0) {
|
||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||
conn.value = undefined
|
||
await redirectToConnections('连接参数无效,请从连接列表重新进入', requestId, isRouteInitCancelled)
|
||
return
|
||
}
|
||
|
||
if (store.connections.length === 0) {
|
||
try {
|
||
await store.fetchConnections()
|
||
} catch {
|
||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||
await redirectToConnections('加载连接列表失败,请稍后重试', requestId, isRouteInitCancelled)
|
||
return
|
||
}
|
||
|
||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||
}
|
||
|
||
const targetConnection = store.getConnection(parsedId)
|
||
if (!targetConnection) {
|
||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||
conn.value = undefined
|
||
await redirectToConnections('连接不存在或已删除', requestId, isRouteInitCancelled)
|
||
return
|
||
}
|
||
|
||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||
conn.value = targetConnection
|
||
sftpTabsStore.openOrFocus(targetConnection)
|
||
|
||
// 只在切换到不同连接时初始化路径
|
||
if (isConnectionChanged) {
|
||
sftpTabsStore.setConnectionLoaded(parsedId)
|
||
await initPath(parsedId, requestId, isRouteInitCancelled)
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
async function redirectToConnections(
|
||
message: string,
|
||
requestId = routeInitRequestId,
|
||
isCancelled?: () => boolean
|
||
) {
|
||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||
toast.error(message)
|
||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||
await router.replace('/connections')
|
||
}
|
||
|
||
async function initPath(
|
||
targetConnectionId = connectionId.value,
|
||
requestId = routeInitRequestId,
|
||
isCancelled?: () => boolean
|
||
) {
|
||
try {
|
||
const res = await sftpApi.getPwd(targetConnectionId)
|
||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||
|
||
const p = res.data.path || '/'
|
||
currentPath.value = p === '/' ? '/' : p
|
||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||
await loadPath(targetConnectionId, requestId, isCancelled)
|
||
} catch (err) {
|
||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||
|
||
const typedErr = err as { response?: { data?: { error?: string } } }
|
||
error.value = typedErr?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
|
||
currentPath.value = '.'
|
||
pathParts.value = []
|
||
await loadPath(targetConnectionId, requestId, isCancelled)
|
||
}
|
||
}
|
||
|
||
async function loadPath(
|
||
targetConnectionId = connectionId.value,
|
||
requestId = routeInitRequestId,
|
||
isCancelled?: () => boolean
|
||
) {
|
||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||
|
||
loading.value = true
|
||
error.value = ''
|
||
searchQuery.value = ''
|
||
|
||
try {
|
||
const res = await sftpApi.listFiles(targetConnectionId, currentPath.value)
|
||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||
|
||
files.value = res.data.sort((a, b) => {
|
||
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
||
return a.name.localeCompare(b.name)
|
||
})
|
||
} catch (err) {
|
||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||
|
||
const typedErr = err as { response?: { data?: { error?: string } } }
|
||
error.value = typedErr?.response?.data?.error ?? '获取文件列表失败'
|
||
} finally {
|
||
if (!isStaleRouteInit(requestId, isCancelled)) {
|
||
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)
|
||
loadPath()
|
||
}
|
||
|
||
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)
|
||
loadPath()
|
||
}
|
||
|
||
function goUp() {
|
||
if (loading.value) return
|
||
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
||
return
|
||
}
|
||
const parts = currentPath.value.split('/').filter(Boolean)
|
||
if (parts.length <= 1) {
|
||
currentPath.value = '/'
|
||
pathParts.value = ['']
|
||
} else {
|
||
parts.pop()
|
||
currentPath.value = '/' + parts.join('/')
|
||
pathParts.value = parts
|
||
}
|
||
loadPath()
|
||
}
|
||
|
||
function handleFileClick(file: SftpFileInfo) {
|
||
if (file.directory) {
|
||
navigateToDir(file.name)
|
||
} else {
|
||
selectedFile.value = file.name
|
||
}
|
||
}
|
||
|
||
function handleDownload(file: SftpFileInfo) {
|
||
if (file.directory) return
|
||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||
const path = base ? base + '/' + file.name : file.name
|
||
sftpApi.downloadFile(connectionId.value, path).catch(() => {
|
||
error.value = '下载失败'
|
||
})
|
||
}
|
||
|
||
function triggerUpload() {
|
||
fileInputRef.value?.click()
|
||
}
|
||
|
||
async function handleFileSelect(e: Event) {
|
||
const uploadContextId = createUploadContext()
|
||
const isUploadStale = () => uploadContextId !== activeUploadContextId
|
||
|
||
const input = e.target as HTMLInputElement
|
||
const selected = input.files
|
||
if (!selected?.length || isUploadStale()) return
|
||
|
||
const targetConnectionId = connectionId.value
|
||
uploading.value = true
|
||
error.value = ''
|
||
const path = currentPath.value === '.' ? '' : currentPath.value
|
||
|
||
const uploadTasks: { id: string; file: File; taskId?: string }[] = []
|
||
for (let i = 0; i < selected.length; i++) {
|
||
const file = selected[i]
|
||
if (!file) continue
|
||
uploadTasks.push({ id: `${Date.now()}-${i}`, file })
|
||
}
|
||
|
||
uploadProgressList.value = uploadTasks.map(({ id, file }) => ({
|
||
id,
|
||
name: file.name,
|
||
size: file.size,
|
||
uploaded: 0,
|
||
total: file.size,
|
||
status: 'pending',
|
||
}))
|
||
|
||
showUploadProgress.value = true
|
||
|
||
const MAX_PARALLEL = 5
|
||
|
||
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
|
||
if (isUploadStale()) return
|
||
|
||
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
|
||
const batchPromises = batch.map(async task => {
|
||
if (!task || isUploadStale()) return
|
||
|
||
const { id, file } = task
|
||
const item = uploadProgressList.value.find(item => item.id === id)
|
||
if (!item || isUploadStale()) return
|
||
|
||
item.status = 'uploading'
|
||
|
||
try {
|
||
if (isUploadStale()) return
|
||
|
||
// Start upload and get taskId
|
||
const uploadRes = await sftpApi.uploadFile(targetConnectionId, path, file)
|
||
if (isUploadStale()) return
|
||
|
||
const taskId = uploadRes.data.taskId
|
||
|
||
// Poll for progress
|
||
while (true) {
|
||
if (isUploadStale()) return
|
||
|
||
const statusRes = await sftpApi.getUploadTask(taskId)
|
||
if (isUploadStale()) return
|
||
|
||
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))
|
||
}
|
||
} catch (err: any) {
|
||
if (isUploadStale()) return
|
||
|
||
item.status = 'error'
|
||
item.message = err?.response?.data?.error || 'Upload failed'
|
||
}
|
||
})
|
||
await Promise.allSettled(batchPromises)
|
||
}
|
||
|
||
if (isUploadStale()) return
|
||
|
||
await loadPath(targetConnectionId)
|
||
if (isUploadStale()) return
|
||
|
||
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
|
||
showUploadProgress.value = false
|
||
uploadProgressList.value = []
|
||
uploading.value = false
|
||
if (fileInputRef.value) {
|
||
fileInputRef.value.value = ''
|
||
}
|
||
|
||
if (isUploadStale()) return
|
||
toast.success(`成功上传 ${successCount} 个文件`)
|
||
}
|
||
|
||
function handleMkdir() {
|
||
const name = prompt('文件夹名称:')
|
||
if (!name?.trim()) return
|
||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||
const path = base ? base + '/' + name : name
|
||
sftpApi.createDir(connectionId.value, path).then(() => loadPath()).catch(() => {
|
||
error.value = '创建文件夹失败'
|
||
})
|
||
}
|
||
|
||
function handleDelete(file: SftpFileInfo) {
|
||
if (!confirm(`确定删除「${file.name}」?`)) return
|
||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||
const path = base ? base + '/' + file.name : file.name
|
||
sftpApi.deleteFile(connectionId.value, path, file.directory).then(() => loadPath()).catch(() => {
|
||
error.value = '删除失败'
|
||
})
|
||
}
|
||
|
||
const targetConnectionOptions = computed(() => {
|
||
const list = store.connections.filter((c) => c.id !== connectionId.value)
|
||
return list
|
||
})
|
||
|
||
async function openTransferModal(file: SftpFileInfo) {
|
||
if (file.directory) return
|
||
if (store.connections.length === 0) await store.fetchConnections()
|
||
transferFile.value = file
|
||
transferTargetConnectionId.value = targetConnectionOptions.value[0]?.id ?? null
|
||
transferTargetPath.value = currentPath.value === '.' || !currentPath.value ? '/' : currentPath.value
|
||
if (!transferTargetPath.value.endsWith('/')) transferTargetPath.value += '/'
|
||
transferError.value = ''
|
||
showTransferModal.value = true
|
||
}
|
||
|
||
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
|
||
const targetId = transferTargetConnectionId.value
|
||
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 = ''
|
||
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>
|
||
<div class="h-full flex flex-col">
|
||
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
|
||
<button
|
||
@click="router.push('/connections')"
|
||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||
aria-label="返回"
|
||
>
|
||
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
|
||
</button>
|
||
<h2 class="text-lg font-semibold text-slate-100">
|
||
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
|
||
</h2>
|
||
</div>
|
||
|
||
<div class="flex-1 overflow-auto p-4">
|
||
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
|
||
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 w-full sm:flex-1">
|
||
<button
|
||
@click="navigateToIndex(-1)"
|
||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 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-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[120px]"
|
||
>
|
||
{{ part || '/' }}
|
||
</button>
|
||
</template>
|
||
</nav>
|
||
<div class="w-full sm:w-auto flex items-center gap-2 justify-end">
|
||
<div class="flex-1 sm:flex-none">
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
class="w-full sm:w-56 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="搜索文件"
|
||
/>
|
||
</div>
|
||
<button
|
||
@click="showHiddenFiles = !showHiddenFiles"
|
||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||
:aria-label="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||
:title="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||
>
|
||
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<button
|
||
@click="triggerUpload"
|
||
:disabled="uploading"
|
||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||
aria-label="上传"
|
||
>
|
||
<Upload class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<button
|
||
@click="handleMkdir"
|
||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||
aria-label="新建文件夹"
|
||
>
|
||
<FolderPlus class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<button
|
||
@click="loadPath()"
|
||
:disabled="loading"
|
||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||
aria-label="刷新"
|
||
>
|
||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
<input
|
||
ref="fileInputRef"
|
||
type="file"
|
||
multiple
|
||
class="hidden"
|
||
@change="handleFileSelect"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="showUploadProgress" class="bg-slate-800/50 border-b border-slate-700 p-4 space-y-3">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-sm text-slate-300">上传进度: {{ totalProgress }}%</span>
|
||
<span class="text-sm text-slate-400">{{ currentUploadingFile || '准备上传...' }}</span>
|
||
</div>
|
||
<div class="w-full bg-slate-700 rounded-full h-2 overflow-hidden">
|
||
<div
|
||
class="bg-cyan-600 h-full transition-all duration-300"
|
||
:style="{ width: totalProgress + '%' }"
|
||
></div>
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-2 max-h-40 overflow-y-auto">
|
||
<div
|
||
v-for="item in uploadProgressList"
|
||
:key="item.id"
|
||
class="flex items-center gap-3 text-sm"
|
||
>
|
||
<CheckCircle v-if="item.status === 'success'" class="w-4 h-4 flex-shrink-0 text-green-500" aria-hidden="true" />
|
||
<AlertCircle v-else-if="item.status === 'error'" class="w-4 h-4 flex-shrink-0 text-red-500" aria-hidden="true" />
|
||
<Loader v-else-if="item.status === 'uploading'" class="w-4 h-4 flex-shrink-0 text-cyan-500 animate-spin" aria-hidden="true" />
|
||
<File v-else class="w-4 h-4 flex-shrink-0 text-slate-500" aria-hidden="true" />
|
||
<span class="flex-1 truncate text-slate-300">{{ item.name }}</span>
|
||
<span class="text-slate-400 text-xs">
|
||
{{ formatSize(item.size) }}
|
||
<template v-if="item.status === 'uploading'">
|
||
({{ Math.round((item.uploaded / item.total) * 100) }}%)
|
||
</template>
|
||
<template v-else-if="item.status === 'success'">
|
||
✓
|
||
</template>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
|
||
|
||
<div v-if="loading" class="p-8 text-center text-slate-400">
|
||
加载中...
|
||
</div>
|
||
|
||
<div v-else class="divide-y divide-slate-700">
|
||
<button
|
||
v-if="currentPath !== '.' && pathParts.length > 1"
|
||
@click="goUp"
|
||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left"
|
||
>
|
||
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
||
<span class="text-slate-400">..</span>
|
||
</button>
|
||
<button
|
||
v-for="file in filteredFiles"
|
||
:key="file.name"
|
||
@click="handleFileClick(file)"
|
||
@dblclick="!file.directory && handleDownload(file)"
|
||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
|
||
>
|
||
<component
|
||
:is="file.directory ? FolderOpen : File"
|
||
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
||
aria-hidden="true"
|
||
/>
|
||
<span class="flex-1 truncate text-slate-200">{{ file.name }}</span>
|
||
<span v-if="!file.directory" class="text-sm text-slate-500 flex-shrink-0">
|
||
{{ formatSize(file.size) }}
|
||
</span>
|
||
<span class="text-sm text-slate-500 flex-shrink-0">{{ formatDate(file.mtime) }}</span>
|
||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
v-if="!file.directory"
|
||
@click.stop="handleDownload(file)"
|
||
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
||
aria-label="下载"
|
||
>
|
||
<Download class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<button
|
||
v-if="!file.directory"
|
||
@click.stop="openTransferModal(file)"
|
||
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
||
aria-label="复制到远程"
|
||
>
|
||
<Copy class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<button
|
||
@click.stop="handleDelete(file)"
|
||
class="p-1.5 rounded text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
|
||
aria-label="删除"
|
||
>
|
||
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</button>
|
||
<div v-if="filteredFiles.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
||
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏文件)') }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="showTransferModal"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="transfer-modal-title"
|
||
>
|
||
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
|
||
<h3 id="transfer-modal-title" class="text-lg font-semibold text-slate-100 mb-4">
|
||
复制到远程
|
||
</h3>
|
||
<p v-if="transferFile" class="text-sm text-slate-400 mb-3">
|
||
文件:{{ transferFile.name }}
|
||
</p>
|
||
<div class="space-y-3">
|
||
<div>
|
||
<label for="transfer-target-conn" class="block text-sm text-slate-400 mb-1">目标连接</label>
|
||
<select
|
||
id="transfer-target-conn"
|
||
v-model.number="transferTargetConnectionId"
|
||
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||
>
|
||
<option v-for="c in targetConnectionOptions" :key="c.id" :value="c.id">
|
||
{{ c.name }} ({{ c.username }}@{{ c.host }})
|
||
</option>
|
||
</select>
|
||
<p v-if="targetConnectionOptions.length === 0" class="mt-1 text-xs text-amber-400">
|
||
暂无其他连接,请先在连接管理中添加
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label for="transfer-target-path" class="block text-sm text-slate-400 mb-1">目标路径(目录以 / 结尾)</label>
|
||
<input
|
||
id="transfer-target-path"
|
||
v-model="transferTargetPath"
|
||
type="text"
|
||
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||
placeholder="/"
|
||
/>
|
||
</div>
|
||
</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"
|
||
:disabled="transferring || transferTargetConnectionId == null"
|
||
class="rounded-lg bg-cyan-600 px-4 py-2 text-white hover:bg-cyan-500 disabled:opacity-50 cursor-pointer"
|
||
>
|
||
{{ transferring ? '传输中...' : '确定' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</div>
|
||
</template>
|