1184 lines
40 KiB
Vue
1184 lines
40 KiB
Vue
<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>
|