feat: 为文件视图添加侧边栏标签页

This commit is contained in:
liumangmang
2026-03-24 17:34:27 +08:00
parent f7fd41b88f
commit 43207e24bf
4 changed files with 426 additions and 134 deletions

View File

@@ -1,10 +1,11 @@
<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 { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'vue-toast-notification'
import { useConnectionsStore } from '../stores/connections'
import { useSftpTabsStore } from '../stores/sftpTabs'
import * as sftpApi from '../api/sftp'
import type { SftpFileInfo } from '../api/sftp'
import {
ArrowLeft,
FolderOpen,
@@ -24,12 +25,13 @@ import {
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useConnectionsStore()
const connectionId = computed(() => Number(route.params.id))
const conn = ref(store.getConnection(connectionId.value))
const router = useRouter()
const toast = useToast()
const store = useConnectionsStore()
const sftpTabsStore = useSftpTabsStore()
const connectionId = computed(() => Number(route.params.id))
const conn = ref(store.getConnection(connectionId.value))
const currentPath = ref('.')
const pathParts = ref<string[]>([])
@@ -59,12 +61,23 @@ watch([searchQuery, showHiddenFiles, files], () => {
}, { immediate: true })
onBeforeUnmount(() => {
invalidateUploadContext()
clearTimeout(searchDebounceTimer)
stopTransferProgress()
})
const showUploadProgress = ref(false)
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
const showUploadProgress = ref(false)
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
let activeUploadContextId = 0
function createUploadContext() {
activeUploadContextId += 1
return activeUploadContextId
}
function invalidateUploadContext() {
activeUploadContextId += 1
}
const totalProgress = computed(() => {
if (uploadProgressList.value.length === 0) return 0
@@ -164,51 +177,153 @@ async function cancelTransfer() {
}
}
onMounted(() => {
conn.value = store.getConnection(connectionId.value)
if (!conn.value) {
store.fetchConnections().then(() => {
conn.value = store.getConnection(connectionId.value)
initPath()
})
} else {
initPath()
}
})
function initPath() {
sftpApi.getPwd(connectionId.value).then((res) => {
const p = res.data.path || '/'
currentPath.value = p === '/' ? '/' : p
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
loadPath()
}).catch((err: { response?: { data?: { error?: string } } }) => {
error.value = err?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
currentPath.value = '.'
pathParts.value = []
loadPath()
})
}
function loadPath() {
loading.value = true
error.value = ''
searchQuery.value = ''
sftpApi
.listFiles(connectionId.value, currentPath.value)
.then((res) => {
files.value = res.data.sort((a, b) => {
if (a.directory !== b.directory) return a.directory ? -1 : 1
return a.name.localeCompare(b.name)
})
})
.catch((err: { response?: { data?: { error?: string } } }) => {
error.value = err?.response?.data?.error ?? '获取文件列表失败'
})
.finally(() => {
loading.value = false
})
}
let routeInitRequestId = 0
function isStaleRouteInit(requestId?: number, isCancelled?: () => boolean) {
return (isCancelled?.() ?? false) || (requestId != null && requestId !== routeInitRequestId)
}
function resetVolatileSftpState() {
invalidateUploadContext()
conn.value = undefined
currentPath.value = '.'
pathParts.value = []
files.value = []
filteredFiles.value = []
loading.value = false
error.value = ''
selectedFile.value = null
searchQuery.value = ''
showUploadProgress.value = false
uploadProgressList.value = []
uploading.value = false
stopTransferProgress()
showTransferModal.value = false
transferFile.value = null
transferTargetConnectionId.value = null
transferTargetPath.value = ''
transferError.value = ''
transferring.value = false
resetTransferProgress()
}
watch(
() => route.params.id,
async (routeId, _, onCleanup) => {
const requestId = ++routeInitRequestId
let cleanedUp = false
onCleanup(() => {
cleanedUp = true
})
const isRouteInitCancelled = () => cleanedUp
resetVolatileSftpState()
const rawId = Array.isArray(routeId) ? routeId[0] : routeId
const parsedId = Number(rawId)
if (!rawId || !Number.isInteger(parsedId) || parsedId <= 0) {
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
conn.value = undefined
await redirectToConnections('连接参数无效,请从连接列表重新进入', requestId, isRouteInitCancelled)
return
}
if (store.connections.length === 0) {
try {
await store.fetchConnections()
} catch {
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
await redirectToConnections('加载连接列表失败,请稍后重试', requestId, isRouteInitCancelled)
return
}
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
}
const targetConnection = store.getConnection(parsedId)
if (!targetConnection) {
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
conn.value = undefined
await redirectToConnections('连接不存在或已删除', requestId, isRouteInitCancelled)
return
}
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
conn.value = targetConnection
sftpTabsStore.openOrFocus(targetConnection)
await initPath(parsedId, requestId, isRouteInitCancelled)
},
{ immediate: true }
)
async function redirectToConnections(
message: string,
requestId = routeInitRequestId,
isCancelled?: () => boolean
) {
if (isStaleRouteInit(requestId, isCancelled)) return
toast.error(message)
if (isStaleRouteInit(requestId, isCancelled)) return
await router.replace('/connections')
}
async function initPath(
targetConnectionId = connectionId.value,
requestId = routeInitRequestId,
isCancelled?: () => boolean
) {
try {
const res = await sftpApi.getPwd(targetConnectionId)
if (isStaleRouteInit(requestId, isCancelled)) return
const p = res.data.path || '/'
currentPath.value = p === '/' ? '/' : p
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
await loadPath(targetConnectionId, requestId, isCancelled)
} catch (err) {
if (isStaleRouteInit(requestId, isCancelled)) return
const typedErr = err as { response?: { data?: { error?: string } } }
error.value = typedErr?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
currentPath.value = '.'
pathParts.value = []
await loadPath(targetConnectionId, requestId, isCancelled)
}
}
async function loadPath(
targetConnectionId = connectionId.value,
requestId = routeInitRequestId,
isCancelled?: () => boolean
) {
if (isStaleRouteInit(requestId, isCancelled)) return
loading.value = true
error.value = ''
searchQuery.value = ''
try {
const res = await sftpApi.listFiles(targetConnectionId, currentPath.value)
if (isStaleRouteInit(requestId, isCancelled)) return
files.value = res.data.sort((a, b) => {
if (a.directory !== b.directory) return a.directory ? -1 : 1
return a.name.localeCompare(b.name)
})
} catch (err) {
if (isStaleRouteInit(requestId, isCancelled)) return
const typedErr = err as { response?: { data?: { error?: string } } }
error.value = typedErr?.response?.data?.error ?? '获取文件列表失败'
} finally {
if (!isStaleRouteInit(requestId, isCancelled)) {
loading.value = false
}
}
}
function navigateToDir(name: string) {
if (loading.value) return
@@ -267,13 +382,18 @@ 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
async function handleFileSelect(e: Event) {
const uploadContextId = createUploadContext()
const isUploadStale = () => uploadContextId !== activeUploadContextId
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length || isUploadStale()) return
const targetConnectionId = connectionId.value
uploading.value = true
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
const uploadTasks: { id: string; file: File; taskId?: string }[] = []
for (let i = 0; i < selected.length; i++) {
@@ -293,29 +413,40 @@ async function handleFileSelect(e: Event) {
showUploadProgress.value = true
const MAX_PARALLEL = 5
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
const batchPromises = batch.map(async task => {
if (!task) return
const { id, file } = task
const item = uploadProgressList.value.find(item => item.id === id)
if (!item) return
item.status = 'uploading'
try {
// Start upload and get taskId
const uploadRes = await sftpApi.uploadFile(connectionId.value, path, file)
const taskId = uploadRes.data.taskId
// Poll for progress
while (true) {
const statusRes = await sftpApi.getUploadTask(taskId)
const taskStatus = statusRes.data
item.uploaded = taskStatus.transferredBytes
const MAX_PARALLEL = 5
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
if (isUploadStale()) return
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
const batchPromises = batch.map(async task => {
if (!task || isUploadStale()) return
const { id, file } = task
const item = uploadProgressList.value.find(item => item.id === id)
if (!item || isUploadStale()) return
item.status = 'uploading'
try {
if (isUploadStale()) return
// Start upload and get taskId
const uploadRes = await sftpApi.uploadFile(targetConnectionId, path, file)
if (isUploadStale()) return
const taskId = uploadRes.data.taskId
// Poll for progress
while (true) {
if (isUploadStale()) return
const statusRes = await sftpApi.getUploadTask(taskId)
if (isUploadStale()) return
const taskStatus = statusRes.data
item.uploaded = taskStatus.transferredBytes
item.total = taskStatus.totalBytes
if (taskStatus.status === 'success') {
@@ -327,24 +458,34 @@ async function handleFileSelect(e: Event) {
item.message = taskStatus.error || 'Upload failed'
break
}
await new Promise(resolve => setTimeout(resolve, 200))
}
} catch (err: any) {
item.status = 'error'
item.message = err?.response?.data?.error || 'Upload failed'
}
})
await Promise.allSettled(batchPromises)
}
await loadPath()
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
showUploadProgress.value = false
uploadProgressList.value = []
uploading.value = false
fileInputRef.value!.value = ''
toast.success(`成功上传 ${successCount} 个文件`)
await new Promise(resolve => setTimeout(resolve, 200))
}
} catch (err: any) {
if (isUploadStale()) return
item.status = 'error'
item.message = err?.response?.data?.error || 'Upload failed'
}
})
await Promise.allSettled(batchPromises)
}
if (isUploadStale()) return
await loadPath(targetConnectionId)
if (isUploadStale()) return
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
showUploadProgress.value = false
uploadProgressList.value = []
uploading.value = false
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
if (isUploadStale()) return
toast.success(`成功上传 ${successCount} 个文件`)
}
function handleMkdir() {
@@ -488,12 +629,12 @@ async function submitTransfer() {
>
<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="刷新"
>
<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>