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:
@@ -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>
|
||||
Reference in New Issue
Block a user