Files
ssh-manager/frontend/src/views/SftpView.vue

844 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>