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:
liumangmang
2026-04-03 15:14:36 +08:00
parent 9f133bd337
commit 2c06329d68
20 changed files with 2288 additions and 506 deletions

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSessionTreeStore } from '../stores/sessionTree'
import { useConnectionsStore } from '../stores/connections'
import type { SessionTreeNode as TreeNode } from '../types/sessionTree'
import { Folder, FolderOpen, ChevronRight, ChevronDown, Server } from 'lucide-vue-next'
interface Props {
node: TreeNode
level: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
click: [nodeId: string]
}>()
const treeStore = useSessionTreeStore()
const connectionsStore = useConnectionsStore()
const children = computed(() => treeStore.getChildren(props.node.id))
const isSelected = computed(() => treeStore.selectedNodeId === props.node.id)
const isExpanded = computed(() => props.node.expanded)
const connection = computed(() => {
if (props.node.type === 'connection' && props.node.connectionId) {
return connectionsStore.connections.find(c => c.id === props.node.connectionId)
}
return null
})
function handleClick() {
emit('click', props.node.id)
}
</script>
<template>
<div>
<div
class="flex items-center gap-1.5 px-2 py-1.5 rounded cursor-pointer hover:bg-slate-800 transition-colors"
:class="{
'bg-slate-800 text-cyan-300': isSelected,
'text-slate-300': !isSelected,
}"
:style="{ paddingLeft: `${level * 16 + 8}px` }"
@click="handleClick"
>
<button
v-if="node.type === 'folder'"
class="w-4 h-4 flex items-center justify-center"
@click.stop="treeStore.toggleExpanded(node.id)"
>
<ChevronDown v-if="isExpanded" class="w-3.5 h-3.5" />
<ChevronRight v-else class="w-3.5 h-3.5" />
</button>
<div v-else class="w-4" />
<component
:is="node.type === 'folder' ? (isExpanded ? FolderOpen : Folder) : Server"
class="w-4 h-4 flex-shrink-0"
:class="{
'text-amber-400': node.type === 'folder',
'text-cyan-400': node.type === 'connection',
}"
/>
<span class="text-sm truncate flex-1">{{ node.name }}</span>
<span
v-if="connection"
class="w-2 h-2 rounded-full bg-slate-600"
/>
</div>
<template v-if="node.type === 'folder' && isExpanded">
<SessionTreeNode
v-for="child in children"
:key="child.id"
:node="child"
:level="level + 1"
@click="emit('click', $event)"
/>
</template>
</div>
</template>