feat: implement MobaXterm-style layout (Phase 1-2-4)
实现 MobaXterm 风格的界面重构,包含会话树、工作区面板和分屏功能。 新增功能: - 左侧会话树支持文件夹分组和展开/折叠 - 工作区垂直分屏(终端 + SFTP) - 可拖拽调整分割比例 - 状态持久化到 localStorage - 顶部工具栏(样式占位) 技术实现: - 新增 sessionTreeStore 和 workspaceStore 状态管理 - 新增 SessionTree/SessionTreeNode 递归组件 - 新增 SplitPane 可拖拽分割组件 - 重构 SftpPanel 为 props 驱动 - 新增 MobaLayout 主布局 - 路由默认重定向到 /moba 依赖更新: - 安装 @vueuse/core 用于拖拽功能 待实现: - Phase 3: 会话树拖拽排序 - Phase 5: 数据迁移 - Phase 6: 快捷键、右键菜单、搜索等优化 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } 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'
|
||||
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen, MonitorCog, Clock3 } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -14,14 +14,31 @@ const authStore = useAuthStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const sftpTabsStore = useSftpTabsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
const now = ref(new Date())
|
||||
let clockTimer = 0
|
||||
|
||||
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(() => {})
|
||||
const currentSectionTitle = computed(() => {
|
||||
if (route.path.startsWith('/connections')) return 'Session Manager'
|
||||
if (route.path.startsWith('/terminal')) return 'Terminal Workspace'
|
||||
if (route.path.startsWith('/sftp/')) return 'SFTP Browser'
|
||||
if (route.path.startsWith('/transfers')) return 'Transfer Queue'
|
||||
return 'SSH Manager'
|
||||
})
|
||||
|
||||
const nowText = computed(() => {
|
||||
return now.value.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
})
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false
|
||||
@@ -66,101 +83,145 @@ function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
|
||||
|
||||
router.push('/connections')
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectionsStore.fetchConnections().catch(() => {})
|
||||
clockTimer = window.setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(clockTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen bg-slate-900">
|
||||
<div class="flex h-screen bg-slate-950 text-slate-100">
|
||||
<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"
|
||||
class="lg:hidden fixed top-3 left-3 z-40 p-2 rounded-md bg-slate-800 border border-slate-600 text-slate-200 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-400 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" />
|
||||
<Menu v-if="!sidebarOpen" class="w-5 h-5" aria-hidden="true" />
|
||||
<X v-else class="w-5 h-5" 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',
|
||||
'w-72 border-r border-slate-700/80 flex flex-col transition-transform duration-200 z-30 bg-slate-900/95 backdrop-blur-sm',
|
||||
'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 class="px-4 py-3 border-b border-slate-700/80">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-md bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
|
||||
<MonitorCog class="w-5 h-5 text-white" aria-hidden="true" />
|
||||
</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' }"
|
||||
<div>
|
||||
<h1 class="text-sm font-semibold tracking-wide text-slate-100">SSH Manager</h1>
|
||||
<p class="text-xs text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 px-3 py-3 overflow-y-auto space-y-4 pt-16 lg:pt-3">
|
||||
<div>
|
||||
<p class="px-2 text-[11px] uppercase tracking-wider text-slate-500 mb-2">工作区</p>
|
||||
<div class="space-y-1">
|
||||
<RouterLink
|
||||
to="/connections"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path === '/connections' }"
|
||||
aria-label="连接列表"
|
||||
>
|
||||
<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>
|
||||
<Server class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Connections</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/transfers"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path === '/transfers' }"
|
||||
aria-label="传输"
|
||||
>
|
||||
<ArrowLeftRight class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Transfers</span>
|
||||
</RouterLink>
|
||||
</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 v-if="terminalTabs.length > 0" class="pt-3 border-t border-slate-700/70">
|
||||
<div class="flex items-center justify-between px-2 mb-2">
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500">Terminal Sessions</p>
|
||||
<span class="text-[11px] text-slate-500">{{ terminalTabs.length }}</span>
|
||||
</div>
|
||||
<div class="space-y-1 mt-2">
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="tab in terminalTabs"
|
||||
:key="tab.id"
|
||||
class="group flex items-center gap-2 rounded-md border border-transparent px-2 py-2 hover:bg-slate-800 transition-colors"
|
||||
:class="{ 'bg-slate-800 border-cyan-500/30': tab.active && route.path === '/terminal' }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleTabClick(tab.id)"
|
||||
class="flex-1 min-w-0 text-left cursor-pointer focus:outline-none"
|
||||
:aria-label="`切换终端会话 ${tab.title}`"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-cyan-400' : 'bg-slate-600'" />
|
||||
<Terminal class="w-3.5 h-3.5 text-slate-400" aria-hidden="true" />
|
||||
<span class="truncate text-sm text-slate-200">{{ tab.title }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="(e) => handleTabClose(tab.id, e)"
|
||||
class="p-1 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200 transition-opacity duration-200 cursor-pointer"
|
||||
aria-label="关闭终端标签"
|
||||
>
|
||||
<X class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sftpTabs.length > 0" class="pt-3 border-t border-slate-700/70">
|
||||
<div class="flex items-center justify-between px-2 mb-2">
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500">File Sessions</p>
|
||||
<span class="text-[11px] text-slate-500">{{ sftpTabs.length }}</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<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}` }"
|
||||
class="group flex items-center gap-2 rounded-md border border-transparent px-2 py-2 hover:bg-slate-800 transition-colors"
|
||||
:class="{ 'bg-slate-800 border-cyan-500/30': 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}`"
|
||||
:aria-label="`切换文件会话 ${tab.title}`"
|
||||
>
|
||||
<span class="truncate text-sm block">{{ tab.title }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-emerald-400' : 'bg-slate-600'" />
|
||||
<FolderOpen class="w-3.5 h-3.5 text-slate-400" aria-hidden="true" />
|
||||
<span class="truncate text-sm text-slate-200">{{ tab.title }}</span>
|
||||
</div>
|
||||
</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"
|
||||
class="p-1 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200 transition-opacity duration-200 cursor-pointer"
|
||||
aria-label="关闭文件标签"
|
||||
>
|
||||
<X class="w-3 h-3" aria-hidden="true" />
|
||||
@@ -169,35 +230,54 @@ function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-slate-700">
|
||||
|
||||
<div class="p-3 border-t border-slate-700/80">
|
||||
<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"
|
||||
@click="handleLogout"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 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"
|
||||
aria-label="退出登录"
|
||||
>
|
||||
<LogOut class="w-5 h-5" aria-hidden="true" />
|
||||
<LogOut class="w-4 h-4" aria-hidden="true" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="lg:hidden fixed inset-0 bg-black/50 z-10"
|
||||
class="lg:hidden fixed inset-0 bg-black/55 z-20"
|
||||
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" />
|
||||
|
||||
<main class="flex-1 min-w-0 overflow-hidden flex flex-col">
|
||||
<header class="h-12 border-b border-slate-700/80 bg-slate-900/70 backdrop-blur px-4 md:px-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<h2 class="text-sm md:text-base font-medium text-slate-100 truncate">{{ currentSectionTitle }}</h2>
|
||||
<div class="hidden md:flex items-center gap-2 text-xs text-slate-400">
|
||||
<span class="px-2 py-1 rounded bg-slate-800 border border-slate-700">TTY {{ terminalTabs.length }}</span>
|
||||
<span class="px-2 py-1 rounded bg-slate-800 border border-slate-700">SFTP {{ sftpTabs.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-slate-400">
|
||||
<Clock3 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span class="tabular-nums">{{ nowText }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user