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
+315
View File
@@ -0,0 +1,315 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useWorkspaceStore } from '../stores/workspace'
import { useConnectionsStore } from '../stores/connections'
import * as sftpApi from '../api/sftp'
import type { SftpFileInfo } from '../api/sftp'
import {
FolderOpen,
File,
Upload,
FolderPlus,
RefreshCw,
Download,
Trash2,
ChevronRight,
} from 'lucide-vue-next'
interface Props {
connectionId: number
active?: boolean
}
const props = defineProps<Props>()
const toast = useToast()
const workspaceStore = useWorkspaceStore()
const connectionsStore = useConnectionsStore()
const conn = computed(() => connectionsStore.getConnection(props.connectionId))
const currentPath = computed(() => {
const panel = workspaceStore.panels[props.connectionId]
return panel?.currentPath || '.'
})
const files = ref<SftpFileInfo[]>([])
const loading = ref(false)
const error = ref('')
const fileInputRef = ref<HTMLInputElement | null>(null)
const pathParts = computed(() => {
const path = currentPath.value
return path === '/' ? [''] : path.split('/').filter(Boolean)
})
async function loadFiles() {
if (!props.active) return
loading.value = true
error.value = ''
try {
const res = await sftpApi.listFiles(props.connectionId, currentPath.value)
files.value = res.data.sort((a, b) => {
if (a.directory !== b.directory) return a.directory ? -1 : 1
return a.name.localeCompare(b.name)
})
} catch (err: any) {
error.value = err?.response?.data?.error ?? '获取文件列表失败'
} finally {
loading.value = false
}
}
function navigateToDir(name: string) {
if (loading.value) return
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
const newPath = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
workspaceStore.updateSftpPath(props.connectionId, newPath)
}
function navigateToIndex(i: number) {
if (loading.value) return
let newPath: string
if (i < 0) {
newPath = '.'
} else {
newPath = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
}
workspaceStore.updateSftpPath(props.connectionId, newPath)
}
function goUp() {
if (loading.value) return
const path = currentPath.value
if (path === '.' || path === '' || path === '/') return
const parts = path.split('/').filter(Boolean)
let newPath: string
if (parts.length <= 1) {
newPath = '/'
} else {
parts.pop()
newPath = '/' + parts.join('/')
}
workspaceStore.updateSftpPath(props.connectionId, newPath)
}
function handleFileClick(file: SftpFileInfo) {
if (file.directory) {
navigateToDir(file.name)
}
}
function handleDownload(file: SftpFileInfo) {
if (file.directory) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const path = base ? base + '/' + file.name : file.name
sftpApi.downloadFile(props.connectionId, path).catch(() => {
toast.error('下载失败')
})
}
function triggerUpload() {
fileInputRef.value?.click()
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length) return
const path = currentPath.value === '.' ? '' : currentPath.value
for (let i = 0; i < selected.length; i++) {
const file = selected[i]
if (!file) continue
try {
await sftpApi.uploadFile(props.connectionId, path, file)
} catch (err: any) {
toast.error(`上传 ${file.name} 失败`)
}
}
await loadFiles()
toast.success(`成功上传 ${selected.length} 个文件`)
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function handleMkdir() {
const name = prompt('文件夹名称:')
if (!name?.trim()) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const path = base ? base + '/' + name : name
sftpApi.createDir(props.connectionId, path)
.then(() => loadFiles())
.catch(() => toast.error('创建文件夹失败'))
}
function handleDelete(file: SftpFileInfo) {
if (!confirm(`确定删除「${file.name}」?`)) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
const path = base ? base + '/' + file.name : file.name
sftpApi.deleteFile(props.connectionId, path, file.directory)
.then(() => loadFiles())
.catch(() => toast.error('删除失败'))
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleString()
}
watch(() => props.active, (active) => {
if (active) loadFiles()
})
watch(currentPath, () => {
loadFiles()
})
onMounted(() => {
if (props.active) loadFiles()
})
</script>
<template>
<div class="h-full flex flex-col bg-slate-950">
<div class="h-11 px-3 border-b border-slate-700 flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<h3 class="text-sm font-semibold text-slate-100 truncate">
{{ conn?.name || 'SFTP' }}
</h3>
</div>
<div class="flex items-center gap-2">
<button
@click="triggerUpload"
class="px-2.5 py-1.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors text-xs flex items-center gap-1.5"
>
<Upload class="w-3.5 h-3.5" />
上传
</button>
<button
@click="handleMkdir"
class="px-2.5 py-1.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors text-xs flex items-center gap-1.5"
>
<FolderPlus class="w-3.5 h-3.5" />
新建
</button>
<button
@click="loadFiles()"
:disabled="loading"
class="px-2.5 py-1.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors text-xs flex items-center gap-1.5"
>
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" />
刷新
</button>
</div>
</div>
<div class="px-3 py-2 border-b border-slate-700/80 bg-slate-900/80">
<nav class="flex items-center gap-1 text-sm text-slate-400 overflow-x-auto">
<button
@click="navigateToIndex(-1)"
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors"
>
/
</button>
<template v-for="(part, i) in pathParts" :key="i">
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" />
<button
@click="navigateToIndex(i)"
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors truncate max-w-[160px]"
>
{{ part || '/' }}
</button>
</template>
</nav>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFileSelect"
/>
</div>
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
<div v-if="loading" class="flex-1 flex items-center justify-center text-slate-400">
加载中...
</div>
<div v-else class="flex-1 overflow-y-auto">
<div class="grid grid-cols-[2.6fr_1fr_1.3fr_100px] gap-2 px-4 py-2 text-xs uppercase tracking-wider text-slate-500 border-b border-slate-700 bg-slate-900/85 sticky top-0">
<span>名称</span>
<span>大小</span>
<span>修改时间</span>
<span class="text-right">操作</span>
</div>
<div class="divide-y divide-slate-800">
<button
v-if="currentPath !== '.' && pathParts.length > 1"
@click="goUp"
class="w-full grid grid-cols-[2.6fr_1fr_1.3fr_100px] gap-2 items-center px-4 py-3 hover:bg-slate-800/60 transition-colors text-left"
>
<span class="flex items-center gap-3">
<FolderOpen class="w-4 h-4 text-slate-500" />
<span class="text-slate-300">..</span>
</span>
<span class="text-slate-600">-</span>
<span class="text-slate-600">-</span>
<span></span>
</button>
<button
v-for="file in files"
:key="file.name"
@click="handleFileClick(file)"
@dblclick="!file.directory && handleDownload(file)"
class="w-full grid grid-cols-[2.6fr_1fr_1.3fr_100px] gap-2 items-center px-4 py-3 hover:bg-slate-800/60 transition-colors text-left group"
>
<span class="flex items-center gap-3 min-w-0">
<component
:is="file.directory ? FolderOpen : File"
class="w-4 h-4 flex-shrink-0 text-slate-400"
/>
<span class="truncate text-sm text-slate-200">{{ file.name }}</span>
</span>
<span class="text-sm text-slate-500">{{ file.directory ? '-' : formatSize(file.size) }}</span>
<span class="text-sm text-slate-500">{{ formatDate(file.mtime) }}</span>
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
v-if="!file.directory"
@click.stop="handleDownload(file)"
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors"
>
<Download class="w-4 h-4" />
</button>
<button
@click.stop="handleDelete(file)"
class="p-1.5 rounded text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</button>
<div v-if="files.length === 0 && !loading" class="p-8 text-center text-slate-500">
空目录
</div>
</div>
</div>
</div>
</template>