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

@@ -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

View 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,
}
})

View File

@@ -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}`)
} }

View File

@@ -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="刷新"