feat: update monitor, terminal, and SFTP interaction flow

This commit is contained in:
liumangmang
2026-04-01 15:22:51 +08:00
parent 832d55c722
commit 9f133bd337
6 changed files with 644 additions and 564 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
defineOptions({ name: 'SftpView' })
<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'
@@ -7,25 +7,25 @@ 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,
import {
ArrowLeft,
FolderOpen,
File,
Upload,
FolderPlus,
RefreshCw,
Eye,
EyeOff,
Download,
Trash2,
ChevronRight,
Copy,
CheckCircle,
AlertCircle,
Loader,
} from 'lucide-vue-next'
const route = useRoute()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useConnectionsStore()
@@ -33,16 +33,16 @@ 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 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
@@ -79,32 +79,32 @@ function createUploadContext() {
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 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)
@@ -177,7 +177,7 @@ async function cancelTransfer() {
transferError.value = res?.response?.data?.error ?? '取消传输失败'
}
}
let routeInitRequestId = 0
function isStaleRouteInit(requestId?: number, isCancelled?: () => boolean) {
@@ -212,7 +212,7 @@ function resetVolatileSftpState() {
watch(
() => route.params.id,
async (routeId, _, onCleanup) => {
async (routeId, _oldRouteId, onCleanup) => {
const requestId = ++routeInitRequestId
let cleanedUp = false
onCleanup(() => {
@@ -220,11 +220,18 @@ watch(
})
const isRouteInitCancelled = () => cleanedUp
resetVolatileSftpState()
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
@@ -255,7 +262,12 @@ watch(
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
conn.value = targetConnection
sftpTabsStore.openOrFocus(targetConnection)
await initPath(parsedId, requestId, isRouteInitCancelled)
// 只在切换到不同连接时初始化路径
if (isConnectionChanged) {
sftpTabsStore.setConnectionLoaded(parsedId)
await initPath(parsedId, requestId, isRouteInitCancelled)
}
},
{ immediate: true }
)
@@ -325,7 +337,7 @@ async function loadPath(
}
}
}
function navigateToDir(name: string) {
if (loading.value) return
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
@@ -344,7 +356,7 @@ function navigateToIndex(i: number) {
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
loadPath()
}
function goUp() {
if (loading.value) return
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
@@ -361,28 +373,28 @@ function goUp() {
}
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()
}
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
@@ -395,25 +407,25 @@ async function handleFileSelect(e: Event) {
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 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) {
@@ -448,17 +460,17 @@ async function handleFileSelect(e: Event) {
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
}
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))
}
@@ -488,42 +500,42 @@ async function handleFileSelect(e: Event) {
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 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()
@@ -534,13 +546,13 @@ function closeTransferModal() {
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
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
@@ -560,43 +572,43 @@ async function submitTransfer() {
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">
</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>
<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
@@ -620,84 +632,84 @@ async function submitTransfer() {
: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>
>
<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>
<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"
@@ -705,90 +717,90 @@ async function submitTransfer() {
@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>
<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>
<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">
@@ -815,17 +827,17 @@ async function submitTransfer() {
>
{{ 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>
<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>