Files
ssh-manager/frontend/src/components/SftpPanel.vue
T
2026-04-21 00:42:50 +08:00

1184 lines
40 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">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useWorkspaceStore } from '../stores/workspace'
import { useConnectionsStore } from '../stores/connections'
import { useSettingsStore } from '../stores/settings'
import * as sftpApi from '../api/sftp'
import type { RemoteTransferTask, SftpFileInfo } from '../api/sftp'
import {
getParentPath,
getPathParts,
isAbsoluteServerPath,
joinAbsolutePath,
normalizeAbsoluteServerPath,
resolveInputPath,
} from '../utils/sftpPath'
import {
AlertCircle,
CheckCircle,
ChevronRight,
Copy,
Download,
Eye,
EyeOff,
File,
FolderOpen,
FolderPlus,
Loader,
RefreshCw,
Search,
Trash2,
Upload,
X,
} from 'lucide-vue-next'
interface Props {
workspaceId: string
connectionId: number
active?: boolean
}
interface UploadProgressItem {
id: string
name: string
size: number
uploaded: number
total: number
status: 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
message?: string
}
type UploadConflictAction = 'overwrite' | 'skip'
const props = defineProps<Props>()
const toast = useToast()
const workspaceStore = useWorkspaceStore()
const connectionsStore = useConnectionsStore()
const settingsStore = useSettingsStore()
const conn = computed(() => connectionsStore.getConnection(props.connectionId))
const currentPath = computed(() => {
return workspaceStore.workspaces[props.workspaceId]?.currentPath || '/'
})
const selectedFiles = computed(() => {
return workspaceStore.workspaces[props.workspaceId]?.selectedFiles || []
})
const pathParts = computed(() => getPathParts(currentPath.value))
const filteredFiles = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
const base = showHiddenFiles.value ? files.value : files.value.filter((file) => !file.name.startsWith('.'))
return q ? base.filter((file) => file.name.toLowerCase().includes(q)) : base
})
const selectedFile = computed(() => {
const fileName = selectedFiles.value[0]
return fileName ? filteredFiles.value.find((file) => file.name === fileName) || null : null
})
const targetConnectionOptions = computed(() => {
return connectionsStore.connections.filter((connection) => connection.id !== props.connectionId)
})
const files = ref<SftpFileInfo[]>([])
const loading = ref(false)
const error = ref('')
const uploading = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const pathInputRef = ref<HTMLInputElement | null>(null)
const pathDraft = ref('/')
const showHiddenFiles = ref(false)
const searchQuery = ref('')
const pathReady = ref(false)
const showUploadProgress = ref(false)
const uploadProgressList = ref<UploadProgressItem[]>([])
let activeUploadContextId = 0
const showUploadConflictModal = ref(false)
const pendingUploadConflictItem = ref<UploadProgressItem | null>(null)
const applyUploadConflictToRemaining = ref(false)
const uploadConflictBatchAction = ref<UploadConflictAction | null>(null)
let pendingUploadConflictResolve: ((result: { action: UploadConflictAction; applyToRemaining: boolean }) => void) | null = null
const showMkdirModal = ref(false)
const mkdirName = ref('')
const showDeleteModal = ref(false)
const pendingDeleteFile = ref<SftpFileInfo | null>(null)
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
let skipNextPathLoad = false
let pathInitializationPromise: Promise<boolean> | null = null
function resetTransientState() {
invalidateUploadContext()
stopTransferProgress()
searchQuery.value = ''
error.value = ''
files.value = []
pathReady.value = false
pathInitializationPromise = null
skipNextPathLoad = false
showUploadProgress.value = false
uploadProgressList.value = []
showMkdirModal.value = false
mkdirName.value = ''
showDeleteModal.value = false
pendingDeleteFile.value = null
showTransferModal.value = false
transferFile.value = null
transferTargetConnectionId.value = null
transferTargetPath.value = ''
transferError.value = ''
transferProgress.value = 0
transferTransferredBytes.value = 0
transferTotalBytes.value = 0
transferTaskId.value = ''
}
watch(currentPath, (path, previousPath) => {
pathDraft.value = isAbsoluteServerPath(path) ? normalizeAbsoluteServerPath(path) : path || '/'
if (path === previousPath) return
if (skipNextPathLoad) {
skipNextPathLoad = false
return
}
if (props.active && pathReady.value) {
void loadFiles()
}
}, { immediate: true })
watch(
() => [props.workspaceId, props.connectionId] as const,
() => {
resetTransientState()
if (props.active) {
void loadFiles()
}
}
)
watch(
() => props.active,
(active) => {
if (active) {
void loadFiles()
}
},
{ immediate: true }
)
watch(filteredFiles, (nextFiles) => {
if (nextFiles.length === 0) {
workspaceStore.updateSelectedFiles(props.workspaceId, [])
return
}
const firstSelected = selectedFiles.value[0]
if (!firstSelected || !nextFiles.some((file) => file.name === firstSelected)) {
const firstFile = nextFiles[0]
if (firstFile) {
workspaceStore.updateSelectedFiles(props.workspaceId, [firstFile.name])
}
}
}, { immediate: true })
onMounted(() => {
window.addEventListener('keydown', handleGlobalShortcuts)
})
onBeforeUnmount(() => {
invalidateUploadContext()
stopTransferProgress()
window.removeEventListener('keydown', handleGlobalShortcuts)
})
function handleGlobalShortcuts(event: KeyboardEvent) {
if (!props.active) return
if (showTransferModal.value || showDeleteModal.value || showMkdirModal.value || showUploadConflictModal.value) return
const key = event.key.toLowerCase()
if ((event.ctrlKey || event.metaKey) && key === 'f') {
event.preventDefault()
searchInputRef.value?.focus()
searchInputRef.value?.select()
return
}
if ((event.ctrlKey || event.metaKey) && key === 'l') {
event.preventDefault()
pathInputRef.value?.focus()
pathInputRef.value?.select()
return
}
if (event.key === 'F5') {
event.preventDefault()
void loadFiles()
return
}
if (event.altKey && event.key === 'ArrowUp') {
event.preventDefault()
goUp()
}
}
function createUploadContext() {
activeUploadContextId += 1
resetUploadConflictState()
return activeUploadContextId
}
function invalidateUploadContext() {
activeUploadContextId += 1
resetUploadConflictState()
}
function closeUploadConflictPrompt() {
showUploadConflictModal.value = false
pendingUploadConflictItem.value = null
applyUploadConflictToRemaining.value = false
pendingUploadConflictResolve = null
}
function resetUploadConflictState() {
uploadConflictBatchAction.value = null
if (!pendingUploadConflictResolve) {
closeUploadConflictPrompt()
return
}
const resolve = pendingUploadConflictResolve
closeUploadConflictPrompt()
resolve({ action: 'skip', applyToRemaining: false })
}
function resetTransferProgress() {
transferProgress.value = 0
transferTransferredBytes.value = 0
transferTotalBytes.value = 0
transferTaskId.value = ''
}
function stopTransferProgress() {
transferPollAbort = true
}
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()
}
function formatTransferBytes(bytes: number) {
return formatSize(Math.max(0, bytes || 0))
}
function resolveSftpError(errorLike: unknown, fallback: string) {
const typedError = errorLike as { response?: { data?: { error?: string; message?: string } } }
return typedError?.response?.data?.error ?? typedError?.response?.data?.message ?? fallback
}
function sortFiles(list: SftpFileInfo[]) {
return list.slice().sort((a, b) => {
if (a.directory !== b.directory) return a.directory ? -1 : 1
return a.name.localeCompare(b.name)
})
}
function isMissingPathError(message: string) {
return /no such file or directory/i.test(message)
}
function updatePath(path: string, options: { skipLoad?: boolean } = {}) {
const normalizedPath = normalizeAbsoluteServerPath(path)
if (options.skipLoad) {
skipNextPathLoad = true
}
workspaceStore.updateSftpPath(props.workspaceId, normalizedPath)
}
async function ensurePathReady() {
if (pathReady.value) return true
if (pathInitializationPromise) return pathInitializationPromise
pathInitializationPromise = (async () => {
const storedPath = (workspaceStore.workspaces[props.workspaceId]?.currentPath || '').trim()
if (isAbsoluteServerPath(storedPath)) {
const normalizedPath = normalizeAbsoluteServerPath(storedPath)
pathReady.value = true
if (normalizedPath !== storedPath) {
updatePath(normalizedPath, { skipLoad: true })
}
return true
}
try {
const res = await sftpApi.getPwd(props.connectionId)
const basePath = normalizeAbsoluteServerPath(res.data.path || '/')
const resolvedPath = resolveInputPath(storedPath || '.', basePath)
pathReady.value = true
updatePath(resolvedPath, { skipLoad: true })
return true
} catch (err) {
error.value = resolveSftpError(err, '获取当前目录失败')
pathReady.value = false
return false
}
})()
try {
return await pathInitializationPromise
} finally {
pathInitializationPromise = null
}
}
async function loadFiles() {
if (!props.active) return
if (!(await ensurePathReady())) return
loading.value = true
error.value = ''
const path = normalizeAbsoluteServerPath(currentPath.value)
try {
const res = await sftpApi.listFiles(props.connectionId, path)
files.value = sortFiles(res.data)
} catch (err) {
const message = resolveSftpError(err, '获取文件列表失败')
const fallbackPath = getParentPath(path)
if (path !== '/' && fallbackPath !== path && isMissingPathError(message)) {
try {
const fallbackRes = await sftpApi.listFiles(props.connectionId, fallbackPath)
files.value = sortFiles(fallbackRes.data)
error.value = ''
updatePath(fallbackPath, { skipLoad: true })
return
} catch {
// Ignore fallback failure and surface the original error.
}
}
error.value = message
} finally {
loading.value = false
}
}
function navigateToDir(name: string) {
if (loading.value) return
updatePath(joinAbsolutePath(currentPath.value, name))
}
function navigateToIndex(index: number) {
if (loading.value) return
if (index < 0) {
updatePath('/')
return
}
const nextPath = pathParts.value.length > 0 ? '/' + pathParts.value.slice(0, index + 1).join('/') : '/'
updatePath(nextPath)
}
function navigateToTypedPath() {
if (loading.value) return
const raw = pathDraft.value.trim()
if (!raw) return
updatePath(resolveInputPath(raw, currentPath.value))
}
function goUp() {
if (loading.value) return
if (normalizeAbsoluteServerPath(currentPath.value) === '/') return
updatePath(getParentPath(currentPath.value))
}
function selectFile(file: SftpFileInfo) {
workspaceStore.updateSelectedFiles(props.workspaceId, [file.name])
}
function handleFileClick(file: SftpFileInfo) {
selectFile(file)
if (file.directory) {
navigateToDir(file.name)
}
}
function handleDownload(file: SftpFileInfo) {
if (file.directory) return
const path = joinAbsolutePath(currentPath.value, file.name)
const safeConnectionName = conn.value?.name?.replace(/[\\/:*?"<>|]+/g, '-').trim()
const downloadName = settingsStore.downloadNamingStrategy === 'connectionPrefix' && conn.value?.name
? `${safeConnectionName || 'connection'}-${file.name}`
: file.name
sftpApi.downloadFile(props.connectionId, path, downloadName).catch(() => {
toast.error('下载失败')
})
}
function triggerUpload() {
fileInputRef.value?.click()
}
async function pollUploadTask(taskId: string, itemId: string, uploadContextId: number) {
while (uploadContextId === activeUploadContextId) {
const res = await sftpApi.getUploadTask(taskId)
if (uploadContextId !== activeUploadContextId) return
const target = uploadProgressList.value.find((item) => item.id === itemId)
if (!target) return
target.uploaded = res.data.transferredBytes
target.total = res.data.totalBytes
if (res.data.status === 'success') {
target.status = 'success'
return
}
if (res.data.status === 'error') {
target.status = 'error'
target.message = res.data.error || '上传失败'
return
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
}
function markUploadItemSkipped(item: UploadProgressItem, message: string) {
item.status = 'skipped'
item.message = message
item.uploaded = item.size
item.total = item.size
}
async function promptUploadConflict(item: UploadProgressItem, uploadContextId: number) {
if (uploadContextId !== activeUploadContextId) {
return { action: 'skip' as UploadConflictAction, applyToRemaining: false }
}
return await new Promise<{ action: UploadConflictAction; applyToRemaining: boolean }>((resolve) => {
pendingUploadConflictItem.value = item
applyUploadConflictToRemaining.value = false
showUploadConflictModal.value = true
pendingUploadConflictResolve = resolve
})
}
function resolveUploadConflict(action: UploadConflictAction) {
if (!pendingUploadConflictResolve) return
const resolve = pendingUploadConflictResolve
const applyToRemaining = applyUploadConflictToRemaining.value
closeUploadConflictPrompt()
resolve({ action, applyToRemaining })
}
async function resolveUploadConflictAction(item: UploadProgressItem, uploadContextId: number) {
if (settingsStore.uploadConflictStrategy === 'overwrite') {
return 'overwrite' as UploadConflictAction
}
if (settingsStore.uploadConflictStrategy === 'skip') {
return 'skip' as UploadConflictAction
}
if (uploadConflictBatchAction.value) {
return uploadConflictBatchAction.value
}
const result = await promptUploadConflict(item, uploadContextId)
if (uploadContextId !== activeUploadContextId) {
return 'skip' as UploadConflictAction
}
if (result.applyToRemaining) {
uploadConflictBatchAction.value = result.action
}
return result.action
}
async function handleFileSelect(event: Event) {
const uploadContextId = createUploadContext()
const input = event.target as HTMLInputElement
const selected = input.files
if (!selected?.length) return
uploading.value = true
showUploadProgress.value = true
error.value = ''
const path = normalizeAbsoluteServerPath(currentPath.value)
uploadProgressList.value = Array.from(selected).map((file, index) => ({
id: `${Date.now()}-${index}`,
name: file.name,
size: file.size,
uploaded: 0,
total: file.size,
status: 'pending',
}))
try {
for (const item of uploadProgressList.value) {
if (uploadContextId !== activeUploadContextId) return
const file = Array.from(selected).find((entry) => entry.name === item.name && entry.size === item.size)
if (!file) continue
const existingFile = files.value.find((entry) => !entry.directory && entry.name === file.name)
if (existingFile) {
const action = await resolveUploadConflictAction(item, uploadContextId)
if (uploadContextId !== activeUploadContextId) return
if (action === 'skip') {
markUploadItemSkipped(
item,
settingsStore.uploadConflictStrategy === 'ask' && !uploadConflictBatchAction.value
? '已取消覆盖'
: '同名文件已跳过',
)
continue
}
}
item.status = 'uploading'
const uploadRes = await sftpApi.uploadFile(props.connectionId, path, file)
await pollUploadTask(uploadRes.data.taskId, item.id, uploadContextId)
}
toast.success(`成功上传 ${uploadProgressList.value.filter((item) => item.status === 'success').length} 个文件`)
await loadFiles()
} catch (err) {
toast.error(resolveSftpError(err, '上传失败'))
} finally {
uploading.value = false
resetUploadConflictState()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
}
function openMkdirModal() {
mkdirName.value = ''
showMkdirModal.value = true
}
async function submitMkdir() {
const name = mkdirName.value.trim()
if (!name) return
const path = joinAbsolutePath(currentPath.value, name)
try {
await sftpApi.createDir(props.connectionId, path)
showMkdirModal.value = false
toast.success('文件夹已创建')
await loadFiles()
} catch (err) {
toast.error(resolveSftpError(err, '创建文件夹失败'))
}
}
function openDeleteModal(file: SftpFileInfo) {
pendingDeleteFile.value = file
showDeleteModal.value = true
}
async function confirmDelete() {
const file = pendingDeleteFile.value
if (!file) return
const path = joinAbsolutePath(currentPath.value, file.name)
try {
await sftpApi.deleteFile(props.connectionId, path, file.directory)
showDeleteModal.value = false
pendingDeleteFile.value = null
toast.success('删除成功')
await loadFiles()
} catch (err) {
toast.error(resolveSftpError(err, '删除失败'))
}
}
async function waitForTransferTask(taskId: string) {
transferPollAbort = false
transferTaskId.value = taskId
while (!transferPollAbort) {
const res = await sftpApi.getRemoteTransferTask(taskId)
const task = res.data as RemoteTransferTask
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)
transferError.value = task.cancelRequested ? '已请求取消传输' : (task.message || '传输正在收尾')
} catch (err) {
transferError.value = resolveSftpError(err, '取消传输失败')
}
}
async function openTransferModal(file: SftpFileInfo) {
if (file.directory) return
if (connectionsStore.connections.length === 0) {
await connectionsStore.fetchConnections().catch(() => {})
}
transferFile.value = file
transferTargetConnectionId.value = targetConnectionOptions.value[0]?.id ?? null
transferTargetPath.value = normalizeAbsoluteServerPath(currentPath.value)
if (!transferTargetPath.value.endsWith('/')) {
transferTargetPath.value += '/'
}
transferError.value = ''
resetTransferProgress()
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 sourcePath = joinAbsolutePath(currentPath.value, 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(props.connectionId, sourcePath, targetId, targetPath)
await waitForTransferTask(created.data.taskId)
transferProgress.value = 100
toast.success('远程传输完成')
await loadFiles()
closeTransferModal()
} catch (err) {
transferError.value = resolveSftpError(err, (err as Error)?.message || '传输失败')
} finally {
stopTransferProgress()
transferring.value = false
}
}
const totalUploadProgress = 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 === 'skipped') return sum + item.size
if (item.status === 'uploading') return sum + item.uploaded
return sum
}, 0)
return totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
})
</script>
<template>
<div class="flex h-full flex-col bg-slate-950">
<div class="border-b border-slate-700 bg-slate-900/80 px-3 py-3">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="truncate text-sm font-semibold text-slate-100">
{{ conn?.name || 'SFTP' }}
</h3>
<p class="mt-1 truncate text-xs text-slate-500">
{{ conn?.username }}@{{ conn?.host }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
@click="triggerUpload"
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-xs text-slate-300 transition-colors hover:border-cyan-500/50 hover:text-slate-100"
>
<Upload class="h-3.5 w-3.5" />
<span>上传</span>
</button>
<button
type="button"
@click="openMkdirModal"
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-xs text-slate-300 transition-colors hover:border-cyan-500/50 hover:text-slate-100"
>
<FolderPlus class="h-3.5 w-3.5" />
<span>新建目录</span>
</button>
<button
type="button"
@click="loadFiles"
:disabled="loading"
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-xs text-slate-300 transition-colors hover:border-cyan-500/50 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
<span>刷新</span>
</button>
<button
type="button"
@click="showHiddenFiles = !showHiddenFiles"
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-colors"
:class="showHiddenFiles
? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200'
: 'border-slate-600 bg-slate-800 text-slate-300 hover:border-cyan-500/50 hover:text-slate-100'"
>
<Eye v-if="showHiddenFiles" class="h-3.5 w-3.5" />
<EyeOff v-else class="h-3.5 w-3.5" />
<span>{{ showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件' }}</span>
</button>
</div>
</div>
<div class="flex flex-wrap items-stretch gap-2">
<div class="relative min-w-0 flex-1 basis-64">
<Search class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
placeholder="搜索文件... (Ctrl/Cmd+F)"
class="h-10 w-full rounded-lg border border-slate-700 bg-slate-900 py-2 pl-9 pr-9 text-sm text-slate-100 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none"
/>
<button
v-if="searchQuery"
type="button"
class="absolute right-2 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded text-slate-500 transition-colors hover:bg-slate-800 hover:text-slate-300"
@click="searchQuery = ''"
aria-label="清除搜索"
>
<X class="h-4 w-4" />
</button>
</div>
<div class="flex h-10 min-w-0 flex-1 basis-72 items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-2">
<input
ref="pathInputRef"
v-model="pathDraft"
type="text"
class="min-w-0 flex-1 bg-transparent px-1 text-sm text-slate-100 focus:outline-none"
placeholder="输入路径 (Ctrl/Cmd+L)"
@keyup.enter="navigateToTypedPath"
/>
<button
type="button"
class="inline-flex h-8 shrink-0 items-center rounded-md bg-slate-800 px-3 text-xs text-slate-200 transition-colors hover:bg-slate-700"
@click="navigateToTypedPath"
>
前往
</button>
</div>
</div>
</div>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFileSelect"
/>
</div>
<div class="border-b border-slate-800 bg-slate-900/60 px-3 py-2">
<nav class="flex items-center gap-1 overflow-x-auto text-sm text-slate-400">
<button
type="button"
@click="navigateToIndex(-1)"
class="rounded px-2 py-1 transition-colors hover:bg-slate-800 hover:text-slate-100"
>
/
</button>
<template v-for="(part, index) in pathParts" :key="`${part}-${index}`">
<ChevronRight class="h-4 w-4 flex-shrink-0 text-slate-600" />
<button
type="button"
@click="navigateToIndex(index)"
class="max-w-[160px] truncate rounded px-2 py-1 transition-colors hover:bg-slate-800 hover:text-slate-100"
>
{{ part || '/' }}
</button>
</template>
</nav>
</div>
<div
v-if="showUploadProgress && uploadProgressList.length > 0"
class="border-b border-slate-800 bg-slate-900/50 px-3 py-3"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-slate-200">上传进度</p>
<p class="mt-1 text-xs text-slate-500">
{{ totalUploadProgress }}% · {{ uploadProgressList.filter((item) => item.status === 'success' || item.status === 'skipped').length }}/{{ uploadProgressList.length }} 完成
</p>
</div>
<button
type="button"
class="inline-flex min-h-[36px] items-center rounded-md border border-slate-700 px-2.5 text-xs text-slate-300 transition-colors hover:bg-slate-800"
@click="showUploadProgress = false"
>
收起
</button>
</div>
<div class="mt-3 space-y-2">
<div
v-for="item in uploadProgressList"
:key="item.id"
class="rounded-lg border border-slate-800 bg-slate-950/70 px-3 py-2"
>
<div class="flex items-center justify-between gap-3 text-xs text-slate-300">
<span class="truncate">{{ item.name }}</span>
<span class="flex items-center gap-1">
<Loader v-if="item.status === 'uploading'" class="h-3.5 w-3.5 animate-spin text-cyan-300" />
<CheckCircle v-else-if="item.status === 'success'" class="h-3.5 w-3.5 text-emerald-300" />
<CheckCircle v-else-if="item.status === 'skipped'" class="h-3.5 w-3.5 text-amber-300" />
<AlertCircle v-else-if="item.status === 'error'" class="h-3.5 w-3.5 text-red-300" />
<span>{{ item.status === 'skipped' ? 'skipped' : item.status }}</span>
</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
<div
class="h-full rounded-full bg-cyan-500 transition-all"
:style="{ width: `${item.total > 0 ? Math.round((item.uploaded / item.total) * 100) : 0}%` }"
/>
</div>
<p class="mt-1 text-[11px] text-slate-500">
{{ formatSize(item.uploaded) }} / {{ formatSize(item.total) }} {{ item.message ? `· ${item.message}` : '' }}
</p>
</div>
</div>
</div>
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
<div v-if="loading" class="flex flex-1 items-center justify-center text-slate-400">
加载中...
</div>
<div v-else class="min-h-0 flex-1 overflow-auto">
<div class="min-w-[720px]">
<div class="sticky top-0 grid grid-cols-[minmax(15rem,2.6fr)_7rem_12rem_7rem] gap-2 border-b border-slate-700 bg-slate-900/95 px-4 py-2 text-xs uppercase tracking-wider text-slate-500">
<span>名称</span>
<span>大小</span>
<span>修改时间</span>
<span class="text-right">操作</span>
</div>
<div class="divide-y divide-slate-800">
<button
v-if="currentPath !== '/'"
type="button"
@click="goUp"
class="grid w-full grid-cols-[minmax(15rem,2.6fr)_7rem_12rem_7rem] gap-2 items-center px-4 py-3 text-left transition-colors hover:bg-slate-800/60"
>
<span class="flex items-center gap-3">
<FolderOpen class="h-4 w-4 text-slate-500" />
<span class="text-slate-300">..</span>
</span>
<span class="text-slate-600">-</span>
<span class="text-slate-600">-</span>
<span />
</button>
<button
v-for="file in filteredFiles"
:key="file.name"
type="button"
@click="handleFileClick(file)"
@dblclick="!file.directory && handleDownload(file)"
class="group grid w-full grid-cols-[minmax(15rem,2.6fr)_7rem_12rem_7rem] gap-2 items-center px-4 py-3 text-left transition-colors hover:bg-slate-800/60"
:class="selectedFile?.name === file.name ? 'bg-slate-800/70' : ''"
>
<span class="flex min-w-0 items-center gap-3">
<component
:is="file.directory ? FolderOpen : File"
class="h-4 w-4 flex-shrink-0"
:class="file.directory ? 'text-amber-300' : 'text-slate-400'"
/>
<span class="truncate text-sm text-slate-200">{{ file.name }}</span>
</span>
<span class="text-sm text-slate-500">{{ file.directory ? '-' : formatSize(file.size) }}</span>
<span class="text-sm text-slate-500">{{ formatDate(file.mtime) }}</span>
<div class="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button
v-if="!file.directory"
type="button"
class="rounded p-1.5 text-slate-400 transition-colors hover:bg-slate-700 hover:text-cyan-300"
@click.stop="handleDownload(file)"
aria-label="下载文件"
>
<Download class="h-4 w-4" />
</button>
<button
v-if="!file.directory"
type="button"
class="rounded p-1.5 text-slate-400 transition-colors hover:bg-slate-700 hover:text-cyan-300"
@click.stop="openTransferModal(file)"
aria-label="远程传输"
>
<Copy class="h-4 w-4" />
</button>
<button
type="button"
class="rounded p-1.5 text-slate-400 transition-colors hover:bg-red-900/50 hover:text-red-300"
@click.stop="openDeleteModal(file)"
aria-label="删除"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</button>
<div v-if="filteredFiles.length === 0" class="p-8 text-center text-slate-500">
{{ searchQuery ? '没有匹配的文件' : '空目录' }}
</div>
</div>
</div>
</div>
<div
v-if="showUploadConflictModal && pendingUploadConflictItem"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
>
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
<h3 class="text-base font-semibold text-slate-100">同名文件已存在</h3>
<p class="mt-2 text-sm text-slate-300">
远端已存在同名文件{{ pendingUploadConflictItem.name }}请选择本次处理方式
</p>
<label class="mt-4 flex items-start gap-3 rounded-lg border border-slate-700 bg-slate-900/70 px-3 py-3 text-sm text-slate-300">
<input
v-model="applyUploadConflictToRemaining"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-slate-600 bg-slate-800 text-cyan-500 focus:ring-cyan-500"
>
<span>对本次剩余冲突文件全部应用该操作</span>
</label>
<div class="mt-5 flex gap-2">
<button
type="button"
class="flex-1 rounded-lg bg-cyan-600 px-3 py-2 text-sm text-white transition-colors hover:bg-cyan-500"
@click="resolveUploadConflict('overwrite')"
>
覆盖
</button>
<button
type="button"
class="flex-1 rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-sm text-slate-200 transition-colors hover:bg-slate-600"
@click="resolveUploadConflict('skip')"
>
跳过
</button>
</div>
</div>
</div>
<div
v-if="showMkdirModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showMkdirModal = false"
>
<div class="w-full max-w-sm rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
<h3 class="text-base font-semibold text-slate-100">新建目录</h3>
<input
v-model="mkdirName"
type="text"
placeholder="目录名称"
class="mt-3 w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-cyan-500 focus:outline-none"
@keyup.enter="submitMkdir"
/>
<div class="mt-4 flex gap-2">
<button
type="button"
class="flex-1 rounded-lg bg-cyan-600 px-3 py-2 text-sm text-white transition-colors hover:bg-cyan-500"
@click="submitMkdir"
>
创建
</button>
<button
type="button"
class="flex-1 rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-sm text-slate-200 transition-colors hover:bg-slate-600"
@click="showMkdirModal = false"
>
取消
</button>
</div>
</div>
</div>
<div
v-if="showDeleteModal && pendingDeleteFile"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showDeleteModal = false"
>
<div class="w-full max-w-sm rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
<h3 class="text-base font-semibold text-slate-100">确认删除</h3>
<p class="mt-2 text-sm text-slate-300">
确定删除{{ pendingDeleteFile.directory ? '目录' : '文件' }}{{ pendingDeleteFile.name }}
</p>
<div class="mt-4 flex gap-2">
<button
type="button"
class="flex-1 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-200 transition-colors hover:bg-red-500/20"
@click="confirmDelete"
>
删除
</button>
<button
type="button"
class="flex-1 rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-sm text-slate-200 transition-colors hover:bg-slate-600"
@click="showDeleteModal = false"
>
取消
</button>
</div>
</div>
</div>
<div
v-if="showTransferModal && transferFile"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="closeTransferModal"
>
<div class="w-full max-w-lg rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
<h3 class="text-base font-semibold text-slate-100">远程传输</h3>
<p class="mt-2 text-sm text-slate-400">
从当前连接复制文件{{ transferFile.name }}到另一台机器
</p>
<div class="mt-4 space-y-3">
<div>
<label class="mb-1 block text-xs text-slate-400">目标连接</label>
<select
v-model="transferTargetConnectionId"
class="w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-cyan-500 focus:outline-none"
>
<option
v-for="target in targetConnectionOptions"
:key="target.id"
:value="target.id"
>
{{ target.name }}
</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">目标路径</label>
<input
v-model="transferTargetPath"
type="text"
class="w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-cyan-500 focus:outline-none"
placeholder="/tmp/"
/>
</div>
<div v-if="transferring" class="rounded-lg border border-slate-700 bg-slate-900/70 p-3">
<div class="flex items-center justify-between text-xs text-slate-300">
<span>传输进度</span>
<span>{{ transferProgress }}%</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
<div class="h-full rounded-full bg-cyan-500 transition-all" :style="{ width: `${transferProgress}%` }" />
</div>
<p class="mt-2 text-xs text-slate-500">
{{ formatTransferBytes(transferTransferredBytes) }} / {{ formatTransferBytes(transferTotalBytes) }}
</p>
</div>
<p v-if="transferError" class="text-sm text-red-400">{{ transferError }}</p>
</div>
<div class="mt-5 flex gap-2">
<button
type="button"
class="flex-1 rounded-lg bg-cyan-600 px-3 py-2 text-sm text-white transition-colors hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="transferring || transferTargetConnectionId == null"
@click="submitTransfer"
>
开始传输
</button>
<button
v-if="transferring"
type="button"
class="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-200 transition-colors hover:bg-amber-500/20"
@click="cancelTransfer"
>
取消
</button>
<button
v-else
type="button"
class="rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-sm text-slate-200 transition-colors hover:bg-slate-600"
@click="closeTransferModal"
>
关闭
</button>
</div>
</div>
</div>
</div>
</template>