增强 SSH/SFTP 稳定性并完善安全校验与前端交互

This commit is contained in:
liumangmang
2026-03-11 23:14:39 +08:00
parent 8845847ce2
commit 085123697e
34 changed files with 1433 additions and 605 deletions

View File

@@ -1,26 +1,31 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'vue-toast-notification'
import { useConnectionsStore } from '../stores/connections'
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,
import {
ArrowLeft,
FolderOpen,
File,
Upload,
FolderPlus,
RefreshCw,
Eye,
EyeOff,
Download,
Trash2,
ChevronRight,
Copy,
CheckCircle,
AlertCircle,
Loader,
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useConnectionsStore()
const connectionId = computed(() => Number(route.params.id))
@@ -31,20 +36,62 @@ 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 uploading = ref(false)
const selectedFile = ref<string | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const showHiddenFiles = ref(false)
const searchQuery = ref('')
const filteredFiles = computed(() => {
let searchDebounceTimer = 0
const filteredFiles = ref<SftpFileInfo[]>([])
function applyFileFilters() {
const q = searchQuery.value.trim().toLowerCase()
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
if (!q) return base
return base.filter((f) => f.name.toLowerCase().includes(q))
filteredFiles.value = q ? base.filter((f) => f.name.toLowerCase().includes(q)) : base
}
watch([searchQuery, showHiddenFiles, files], () => {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
applyFileFilters()
}, 300)
}, { immediate: true })
onBeforeUnmount(() => {
clearTimeout(searchDebounceTimer)
})
const showTransferModal = ref(false)
const showUploadProgress = ref(false)
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
const lastUpdate = ref(0)
const totalProgress = computed(() => {
if (uploadProgressList.value.length === 0) return 0
const totalSize = uploadProgressList.value.reduce((sum, item) => sum + item.size, 0)
const uploadedSize = uploadProgressList.value.reduce((sum, item) => {
if (item.status === 'success') return sum + item.size
if (item.status === 'uploading') return sum + item.uploaded
return sum
}, 0)
return totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
})
const currentUploadingFile = computed(() => {
return uploadProgressList.value.find(item => item.status === 'uploading')?.name || ''
})
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleString()
}
const showTransferModal = ref(false)
const transferFile = ref<SftpFileInfo | null>(null)
const transferTargetConnectionId = ref<number | null>(null)
const transferTargetPath = ref('')
@@ -66,7 +113,7 @@ onMounted(() => {
function initPath() {
sftpApi.getPwd(connectionId.value).then((res) => {
const p = res.data.path || '/'
currentPath.value = p || '.'
currentPath.value = p === '/' ? '/' : p
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
loadPath()
}).catch((err: { response?: { data?: { error?: string } } }) => {
@@ -153,27 +200,79 @@ function triggerUpload() {
fileInputRef.value?.click()
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length) return
uploading.value = true
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
try {
for (let i = 0; i < selected.length; i++) {
const file = selected[i]
if (!file) continue
await sftpApi.uploadFile(connectionId.value, path, file)
}
loadPath()
} catch (err: unknown) {
const res = err as { response?: { data?: { error?: string } } }
error.value = res?.response?.data?.error ?? '上传失败'
} finally {
uploading.value = false
input.value = ''
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length) return
uploading.value = true
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
const uploadTasks: { id: string; file: File }[] = []
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
const results: Promise<void>[] = []
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
const batchPromises = batch.map(task => {
if (!task) return Promise.resolve()
const { id, file } = task
const item = uploadProgressList.value.find(item => item.id === id)
if (!item) return Promise.resolve()
item.status = 'uploading'
return new Promise<void>((resolve, reject) => {
const onProgress = (percent: number) => {
const now = Date.now()
if (now - (lastUpdate.value || 0) > 100) {
item.uploaded = Math.round((file.size * percent) / 100)
item.total = file.size
lastUpdate.value = now
}
}
const xhr = sftpApi.uploadFileWithProgress(connectionId.value, path, file)
xhr.onProgress = onProgress
xhr.onload = () => {
item.status = 'success'
resolve()
}
xhr.onerror = () => {
item.status = 'error'
item.message = 'Network error'
reject(new Error('Network error'))
}
})
})
results.push(...batchPromises)
await Promise.allSettled(batchPromises)
}
await Promise.allSettled(results)
await loadPath()
showUploadProgress.value = false
uploadProgressList.value = []
uploading.value = false
fileInputRef.value!.value = ''
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
toast.success(`成功上传 ${successCount} 个文件`)
}
function handleMkdir() {
@@ -240,16 +339,6 @@ async function submitTransfer() {
transferring.value = false
}
}
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()
}
</script>
<template>
@@ -338,6 +427,41 @@ function formatDate(ts: number): string {
/>
</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">
@@ -397,7 +521,7 @@ function formatDate(ts: number): string {
</div>
</button>
<div v-if="filteredFiles.length === 0 && !loading" class="p-8 text-center text-slate-500">
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件已隐藏隐藏文件') }}
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件已隐藏文件') }}
</div>
</div>
</div>