feat: 为文件视图添加侧边栏标签页
This commit is contained in:
@@ -3,18 +3,21 @@ import { ref, computed } from 'vue'
|
|||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
|
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||||
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
|
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
|
||||||
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal } from 'lucide-vue-next'
|
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen } from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const connectionsStore = useConnectionsStore()
|
const connectionsStore = useConnectionsStore()
|
||||||
|
const sftpTabsStore = useSftpTabsStore()
|
||||||
const tabsStore = useTerminalTabsStore()
|
const tabsStore = useTerminalTabsStore()
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
|
|
||||||
const terminalTabs = computed(() => tabsStore.tabs)
|
const terminalTabs = computed(() => tabsStore.tabs)
|
||||||
|
const sftpTabs = computed(() => sftpTabsStore.tabs)
|
||||||
const showTerminalWorkspace = computed(() => route.path === '/terminal')
|
const showTerminalWorkspace = computed(() => route.path === '/terminal')
|
||||||
const keepTerminalWorkspaceMounted = computed(() => showTerminalWorkspace.value || terminalTabs.value.length > 0)
|
const keepTerminalWorkspaceMounted = computed(() => showTerminalWorkspace.value || terminalTabs.value.length > 0)
|
||||||
|
|
||||||
@@ -34,6 +37,35 @@ function handleTabClose(tabId: string, event: Event) {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
tabsStore.close(tabId)
|
tabsStore.close(tabId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCurrentSftpRoute(connectionId: number) {
|
||||||
|
if (route.name !== 'Sftp') return false
|
||||||
|
|
||||||
|
const routeParamId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
|
return Number(routeParamId) === connectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSftpTabClick(tabId: string, connectionId: number) {
|
||||||
|
sftpTabsStore.activate(tabId)
|
||||||
|
router.push(`/sftp/${connectionId}`)
|
||||||
|
closeSidebar()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const shouldNavigate = isCurrentSftpRoute(connectionId)
|
||||||
|
sftpTabsStore.close(tabId)
|
||||||
|
|
||||||
|
if (!shouldNavigate) return
|
||||||
|
|
||||||
|
if (sftpTabsStore.activeTab) {
|
||||||
|
router.push(`/sftp/${sftpTabsStore.activeTab.connectionId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/connections')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -104,6 +136,38 @@ function handleTabClose(tabId: string, event: Event) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="sftpTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<span>文件</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 mt-2">
|
||||||
|
<div
|
||||||
|
v-for="tab in sftpTabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 min-h-[44px] group focus-within:outline-none focus-within:ring-2 focus-within:ring-cyan-500 focus-within:ring-inset"
|
||||||
|
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === `/sftp/${tab.connectionId}` }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="handleSftpTabClick(tab.id, tab.connectionId)"
|
||||||
|
class="flex-1 min-w-0 text-left cursor-pointer focus:outline-none"
|
||||||
|
:aria-label="`打开文件标签 ${tab.title}`"
|
||||||
|
>
|
||||||
|
<span class="truncate text-sm block">{{ tab.title }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="(e) => handleSftpTabClose(tab.id, tab.connectionId, e)"
|
||||||
|
class="p-1 rounded opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100 hover:bg-slate-600 transition-opacity duration-200 transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
aria-label="关闭文件标签"
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="p-4 border-t border-slate-700">
|
<div class="p-4 border-t border-slate-700">
|
||||||
<button
|
<button
|
||||||
|
|||||||
84
frontend/src/stores/sftpTabs.ts
Normal file
84
frontend/src/stores/sftpTabs.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Connection } from '../api/connections'
|
||||||
|
|
||||||
|
export interface SftpTab {
|
||||||
|
id: string
|
||||||
|
connectionId: number
|
||||||
|
title: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
||||||
|
const tabs = ref<SftpTab[]>([])
|
||||||
|
const activeTabId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
|
||||||
|
|
||||||
|
function generateTabId() {
|
||||||
|
return `tab-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOrFocus(connection: Connection) {
|
||||||
|
const existing = tabs.value.find(t => t.connectionId === connection.id)
|
||||||
|
if (existing) {
|
||||||
|
activate(existing.id)
|
||||||
|
return existing.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTab: SftpTab = {
|
||||||
|
id: generateTabId(),
|
||||||
|
connectionId: connection.id,
|
||||||
|
title: connection.name,
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.value.push(newTab)
|
||||||
|
activate(newTab.id)
|
||||||
|
return newTab.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActiveState() {
|
||||||
|
const hasActiveTab = activeTabId.value !== null && tabs.value.some(t => t.id === activeTabId.value)
|
||||||
|
if (!hasActiveTab) {
|
||||||
|
activeTabId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.value.forEach(t => {
|
||||||
|
t.active = t.id === activeTabId.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(tabId: string) {
|
||||||
|
if (!tabs.value.some(t => t.id === tabId)) return
|
||||||
|
|
||||||
|
activeTabId.value = tabId
|
||||||
|
syncActiveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(tabId: string) {
|
||||||
|
const index = tabs.value.findIndex(t => t.id === tabId)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
const wasActive = activeTabId.value === tabId
|
||||||
|
tabs.value.splice(index, 1)
|
||||||
|
|
||||||
|
if (wasActive && tabs.value.length > 0) {
|
||||||
|
const newIndex = Math.min(index, tabs.value.length - 1)
|
||||||
|
activeTabId.value = tabs.value[newIndex]!.id
|
||||||
|
} else if (tabs.value.length === 0) {
|
||||||
|
activeTabId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
syncActiveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
activeTab,
|
||||||
|
openOrFocus,
|
||||||
|
activate,
|
||||||
|
close,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref, onMounted, watch } from 'vue'
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
|
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||||
@@ -22,6 +23,7 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useConnectionsStore()
|
const store = useConnectionsStore()
|
||||||
const tabsStore = useTerminalTabsStore()
|
const tabsStore = useTerminalTabsStore()
|
||||||
|
const sftpTabsStore = useSftpTabsStore()
|
||||||
|
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const editingConn = ref<Connection | null>(null)
|
const editingConn = ref<Connection | null>(null)
|
||||||
@@ -95,6 +97,7 @@ function openTerminal(conn: Connection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSftp(conn: Connection) {
|
function openSftp(conn: Connection) {
|
||||||
|
sftpTabsStore.openOrFocus(conn)
|
||||||
router.push(`/sftp/${conn.id}`)
|
router.push(`/sftp/${conn.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
|
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||||
import * as sftpApi from '../api/sftp'
|
import * as sftpApi from '../api/sftp'
|
||||||
import type { SftpFileInfo } from '../api/sftp'
|
import type { SftpFileInfo } from '../api/sftp'
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +28,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const store = useConnectionsStore()
|
const store = useConnectionsStore()
|
||||||
|
const sftpTabsStore = useSftpTabsStore()
|
||||||
|
|
||||||
const connectionId = computed(() => Number(route.params.id))
|
const connectionId = computed(() => Number(route.params.id))
|
||||||
const conn = ref(store.getConnection(connectionId.value))
|
const conn = ref(store.getConnection(connectionId.value))
|
||||||
@@ -59,12 +61,23 @@ watch([searchQuery, showHiddenFiles, files], () => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
invalidateUploadContext()
|
||||||
clearTimeout(searchDebounceTimer)
|
clearTimeout(searchDebounceTimer)
|
||||||
stopTransferProgress()
|
stopTransferProgress()
|
||||||
})
|
})
|
||||||
|
|
||||||
const showUploadProgress = 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 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(() => {
|
const totalProgress = computed(() => {
|
||||||
if (uploadProgressList.value.length === 0) return 0
|
if (uploadProgressList.value.length === 0) return 0
|
||||||
@@ -164,50 +177,152 @@ async function cancelTransfer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
let routeInitRequestId = 0
|
||||||
conn.value = store.getConnection(connectionId.value)
|
|
||||||
if (!conn.value) {
|
function isStaleRouteInit(requestId?: number, isCancelled?: () => boolean) {
|
||||||
store.fetchConnections().then(() => {
|
return (isCancelled?.() ?? false) || (requestId != null && requestId !== routeInitRequestId)
|
||||||
conn.value = store.getConnection(connectionId.value)
|
|
||||||
initPath()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
initPath()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
})
|
})
|
||||||
|
|
||||||
function initPath() {
|
const isRouteInitCancelled = () => cleanedUp
|
||||||
sftpApi.getPwd(connectionId.value).then((res) => {
|
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 || '/'
|
const p = res.data.path || '/'
|
||||||
currentPath.value = p === '/' ? '/' : p
|
currentPath.value = p === '/' ? '/' : p
|
||||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||||
loadPath()
|
await loadPath(targetConnectionId, requestId, isCancelled)
|
||||||
}).catch((err: { response?: { data?: { error?: string } } }) => {
|
} catch (err) {
|
||||||
error.value = err?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
|
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||||
|
|
||||||
|
const typedErr = err as { response?: { data?: { error?: string } } }
|
||||||
|
error.value = typedErr?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
|
||||||
currentPath.value = '.'
|
currentPath.value = '.'
|
||||||
pathParts.value = []
|
pathParts.value = []
|
||||||
loadPath()
|
await loadPath(targetConnectionId, requestId, isCancelled)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPath() {
|
async function loadPath(
|
||||||
|
targetConnectionId = connectionId.value,
|
||||||
|
requestId = routeInitRequestId,
|
||||||
|
isCancelled?: () => boolean
|
||||||
|
) {
|
||||||
|
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
sftpApi
|
|
||||||
.listFiles(connectionId.value, currentPath.value)
|
try {
|
||||||
.then((res) => {
|
const res = await sftpApi.listFiles(targetConnectionId, currentPath.value)
|
||||||
|
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||||
|
|
||||||
files.value = res.data.sort((a, b) => {
|
files.value = res.data.sort((a, b) => {
|
||||||
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err: { response?: { data?: { error?: string } } }) => {
|
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||||
error.value = err?.response?.data?.error ?? '获取文件列表失败'
|
|
||||||
})
|
const typedErr = err as { response?: { data?: { error?: string } } }
|
||||||
.finally(() => {
|
error.value = typedErr?.response?.data?.error ?? '获取文件列表失败'
|
||||||
|
} finally {
|
||||||
|
if (!isStaleRouteInit(requestId, isCancelled)) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToDir(name: string) {
|
function navigateToDir(name: string) {
|
||||||
@@ -268,9 +383,14 @@ function triggerUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileSelect(e: Event) {
|
async function handleFileSelect(e: Event) {
|
||||||
|
const uploadContextId = createUploadContext()
|
||||||
|
const isUploadStale = () => uploadContextId !== activeUploadContextId
|
||||||
|
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
const selected = input.files
|
const selected = input.files
|
||||||
if (!selected?.length) return
|
if (!selected?.length || isUploadStale()) return
|
||||||
|
|
||||||
|
const targetConnectionId = connectionId.value
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||||
@@ -296,23 +416,34 @@ async function handleFileSelect(e: Event) {
|
|||||||
const MAX_PARALLEL = 5
|
const MAX_PARALLEL = 5
|
||||||
|
|
||||||
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
|
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
|
||||||
|
if (isUploadStale()) return
|
||||||
|
|
||||||
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
|
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
|
||||||
const batchPromises = batch.map(async task => {
|
const batchPromises = batch.map(async task => {
|
||||||
if (!task) return
|
if (!task || isUploadStale()) return
|
||||||
|
|
||||||
const { id, file } = task
|
const { id, file } = task
|
||||||
const item = uploadProgressList.value.find(item => item.id === id)
|
const item = uploadProgressList.value.find(item => item.id === id)
|
||||||
if (!item) return
|
if (!item || isUploadStale()) return
|
||||||
|
|
||||||
item.status = 'uploading'
|
item.status = 'uploading'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isUploadStale()) return
|
||||||
|
|
||||||
// Start upload and get taskId
|
// Start upload and get taskId
|
||||||
const uploadRes = await sftpApi.uploadFile(connectionId.value, path, file)
|
const uploadRes = await sftpApi.uploadFile(targetConnectionId, path, file)
|
||||||
|
if (isUploadStale()) return
|
||||||
|
|
||||||
const taskId = uploadRes.data.taskId
|
const taskId = uploadRes.data.taskId
|
||||||
|
|
||||||
// Poll for progress
|
// Poll for progress
|
||||||
while (true) {
|
while (true) {
|
||||||
|
if (isUploadStale()) return
|
||||||
|
|
||||||
const statusRes = await sftpApi.getUploadTask(taskId)
|
const statusRes = await sftpApi.getUploadTask(taskId)
|
||||||
|
if (isUploadStale()) return
|
||||||
|
|
||||||
const taskStatus = statusRes.data
|
const taskStatus = statusRes.data
|
||||||
|
|
||||||
item.uploaded = taskStatus.transferredBytes
|
item.uploaded = taskStatus.transferredBytes
|
||||||
@@ -331,6 +462,8 @@ async function handleFileSelect(e: Event) {
|
|||||||
await new Promise(resolve => setTimeout(resolve, 200))
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (isUploadStale()) return
|
||||||
|
|
||||||
item.status = 'error'
|
item.status = 'error'
|
||||||
item.message = err?.response?.data?.error || 'Upload failed'
|
item.message = err?.response?.data?.error || 'Upload failed'
|
||||||
}
|
}
|
||||||
@@ -338,12 +471,20 @@ async function handleFileSelect(e: Event) {
|
|||||||
await Promise.allSettled(batchPromises)
|
await Promise.allSettled(batchPromises)
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadPath()
|
if (isUploadStale()) return
|
||||||
|
|
||||||
|
await loadPath(targetConnectionId)
|
||||||
|
if (isUploadStale()) return
|
||||||
|
|
||||||
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
|
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
|
||||||
showUploadProgress.value = false
|
showUploadProgress.value = false
|
||||||
uploadProgressList.value = []
|
uploadProgressList.value = []
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
fileInputRef.value!.value = ''
|
if (fileInputRef.value) {
|
||||||
|
fileInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUploadStale()) return
|
||||||
toast.success(`成功上传 ${successCount} 个文件`)
|
toast.success(`成功上传 ${successCount} 个文件`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +630,7 @@ async function submitTransfer() {
|
|||||||
<FolderPlus class="w-4 h-4" aria-hidden="true" />
|
<FolderPlus class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="loadPath"
|
@click="loadPath()"
|
||||||
:disabled="loading"
|
: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"
|
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="刷新"
|
aria-label="刷新"
|
||||||
|
|||||||
Reference in New Issue
Block a user