204 lines
8.8 KiB
Vue
204 lines
8.8 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
|
import { useAuthStore } from '../stores/auth'
|
|
import { useConnectionsStore } from '../stores/connections'
|
|
import { useSftpTabsStore } from '../stores/sftpTabs'
|
|
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
|
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
|
|
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen } from 'lucide-vue-next'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const authStore = useAuthStore()
|
|
const connectionsStore = useConnectionsStore()
|
|
const sftpTabsStore = useSftpTabsStore()
|
|
const tabsStore = useTerminalTabsStore()
|
|
const sidebarOpen = ref(false)
|
|
|
|
const terminalTabs = computed(() => tabsStore.tabs)
|
|
const sftpTabs = computed(() => sftpTabsStore.tabs)
|
|
const showTerminalWorkspace = computed(() => route.path === '/terminal')
|
|
const keepTerminalWorkspaceMounted = computed(() => showTerminalWorkspace.value || terminalTabs.value.length > 0)
|
|
|
|
connectionsStore.fetchConnections().catch(() => {})
|
|
|
|
function closeSidebar() {
|
|
sidebarOpen.value = false
|
|
}
|
|
|
|
function handleTabClick(tabId: string) {
|
|
tabsStore.activate(tabId)
|
|
router.push('/terminal')
|
|
closeSidebar()
|
|
}
|
|
|
|
function handleTabClose(tabId: string, event: Event) {
|
|
event.stopPropagation()
|
|
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>
|
|
|
|
<template>
|
|
<div class="flex h-screen bg-slate-900">
|
|
<button
|
|
@click="sidebarOpen = !sidebarOpen"
|
|
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 cursor-pointer"
|
|
aria-label="切换侧边栏"
|
|
>
|
|
<Menu v-if="!sidebarOpen" class="w-6 h-6" aria-hidden="true" />
|
|
<X v-else class="w-6 h-6" aria-hidden="true" />
|
|
</button>
|
|
<aside
|
|
:class="[
|
|
'w-64 bg-slate-800 border-r border-slate-700 flex flex-col transition-transform duration-200 z-20',
|
|
'fixed lg:static inset-y-0 left-0',
|
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
|
]"
|
|
>
|
|
<div class="p-4 border-b border-slate-700">
|
|
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
|
|
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
|
</div>
|
|
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4 overflow-y-auto">
|
|
<RouterLink
|
|
to="/connections"
|
|
@click="closeSidebar"
|
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
|
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
|
aria-label="连接列表"
|
|
>
|
|
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
|
<span>连接列表</span>
|
|
</RouterLink>
|
|
<RouterLink
|
|
to="/transfers"
|
|
@click="closeSidebar"
|
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
|
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/transfers' }"
|
|
aria-label="传输"
|
|
>
|
|
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
|
<span>传输</span>
|
|
</RouterLink>
|
|
|
|
<!-- 终端标签区域 -->
|
|
<div v-if="terminalTabs.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">
|
|
<Terminal class="w-4 h-4" aria-hidden="true" />
|
|
<span>终端</span>
|
|
</div>
|
|
<div class="space-y-1 mt-2">
|
|
<button
|
|
v-for="tab in terminalTabs"
|
|
:key="tab.id"
|
|
@click="handleTabClick(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 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset group"
|
|
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === '/terminal' }"
|
|
>
|
|
<span class="truncate text-sm">{{ tab.title }}</span>
|
|
<button
|
|
@click="(e) => handleTabClose(tab.id, e)"
|
|
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-all duration-200 flex-shrink-0"
|
|
aria-label="关闭标签"
|
|
>
|
|
<X class="w-3 h-3" aria-hidden="true" />
|
|
</button>
|
|
</button>
|
|
</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>
|
|
<div class="p-4 border-t border-slate-700">
|
|
<button
|
|
@click="authStore.logout(); $router.push('/login')"
|
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
|
aria-label="退出登录"
|
|
>
|
|
<LogOut class="w-5 h-5" aria-hidden="true" />
|
|
<span>退出登录</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
<div
|
|
v-if="sidebarOpen"
|
|
class="lg:hidden fixed inset-0 bg-black/50 z-10"
|
|
aria-hidden="true"
|
|
@click="sidebarOpen = false"
|
|
/>
|
|
<main class="flex-1 overflow-auto min-w-0">
|
|
<div v-if="keepTerminalWorkspaceMounted" v-show="showTerminalWorkspace" class="h-full">
|
|
<TerminalWorkspaceView :visible="showTerminalWorkspace" />
|
|
</div>
|
|
<RouterView v-slot="{ Component, route }">
|
|
<template v-if="!showTerminalWorkspace">
|
|
<keep-alive :max="10" v-if="route.meta.keepAlive">
|
|
<component :is="Component" :key="route.params.id" />
|
|
</keep-alive>
|
|
<component :is="Component" :key="route.fullPath" v-else />
|
|
</template>
|
|
</RouterView>
|
|
</main>
|
|
</div>
|
|
</template>
|