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,91 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSessionTreeStore } from '../stores/sessionTree'
import { useWorkspaceStore } from '../stores/workspace'
import SessionTreeNode from './SessionTreeNode.vue'
import { FolderPlus } from 'lucide-vue-next'
const treeStore = useSessionTreeStore()
const workspaceStore = useWorkspaceStore()
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
const rootNodes = computed(() => treeStore.rootNodes)
function handleNodeClick(nodeId: string) {
const node = treeStore.nodes.find(n => n.id === nodeId)
if (!node) return
if (node.type === 'folder') {
treeStore.toggleExpanded(nodeId)
} else if (node.type === 'connection' && node.connectionId) {
workspaceStore.openPanel(node.connectionId)
}
treeStore.selectNode(nodeId)
}
function createFolder() {
if (!newFolderName.value.trim()) return
treeStore.createFolder(newFolderName.value.trim())
newFolderName.value = ''
showNewFolderDialog.value = false
}
</script>
<template>
<div class="h-full flex flex-col bg-slate-900 border-r border-slate-700">
<div class="h-12 px-3 flex items-center gap-2 border-b border-slate-700">
<button
@click="showNewFolderDialog = true"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs text-slate-300 hover:bg-slate-800 transition-colors"
title="新建文件夹"
>
<FolderPlus class="w-4 h-4" />
文件夹
</button>
</div>
<div class="flex-1 overflow-y-auto p-2">
<SessionTreeNode
v-for="node in rootNodes"
:key="node.id"
:node="node"
:level="0"
@click="handleNodeClick"
/>
</div>
<div
v-if="showNewFolderDialog"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
@click.self="showNewFolderDialog = false"
>
<div class="bg-slate-800 rounded-lg p-4 w-80">
<h3 class="text-sm font-medium text-slate-100 mb-3">新建文件夹</h3>
<input
v-model="newFolderName"
type="text"
placeholder="文件夹名称"
class="w-full px-3 py-2 rounded bg-slate-900 border border-slate-600 text-slate-100 text-sm focus:border-cyan-500 focus:outline-none"
@keyup.enter="createFolder"
/>
<div class="flex gap-2 mt-4">
<button
@click="createFolder"
class="flex-1 px-3 py-2 rounded bg-cyan-600 hover:bg-cyan-500 text-white text-sm"
>
创建
</button>
<button
@click="showNewFolderDialog = false"
class="flex-1 px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm"
>
取消
</button>
</div>
</div>
</div>
</div>
</template>

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>

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>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
interface Props {
direction?: 'horizontal' | 'vertical'
initialRatio?: number
minRatio?: number
maxRatio?: number
}
const props = withDefaults(defineProps<Props>(), {
direction: 'vertical',
initialRatio: 0.5,
minRatio: 0.2,
maxRatio: 0.8,
})
const emit = defineEmits<{
ratioChange: [ratio: number]
}>()
const containerRef = ref<HTMLElement>()
const ratio = ref(props.initialRatio)
const isDragging = ref(false)
function handleMouseDown(event: MouseEvent) {
event.preventDefault()
isDragging.value = true
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value || !containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
let newRatio: number
if (props.direction === 'vertical') {
newRatio = (event.clientY - rect.top) / rect.height
} else {
newRatio = (event.clientX - rect.left) / rect.width
}
ratio.value = Math.max(props.minRatio, Math.min(props.maxRatio, newRatio))
emit('ratioChange', ratio.value)
}
function handleMouseUp() {
isDragging.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
})
</script>
<template>
<div
ref="containerRef"
class="relative w-full h-full"
:class="{
'flex flex-col': direction === 'vertical',
'flex flex-row': direction === 'horizontal',
}"
>
<div
class="overflow-hidden"
:style="{
[direction === 'vertical' ? 'height' : 'width']: `${ratio * 100}%`,
}"
>
<slot name="first" />
</div>
<div
class="bg-slate-700 hover:bg-cyan-500 transition-colors flex-shrink-0"
:class="{
'h-1 cursor-row-resize': direction === 'vertical',
'w-1 cursor-col-resize': direction === 'horizontal',
'bg-cyan-500': isDragging,
}"
@mousedown="handleMouseDown"
/>
<div class="overflow-hidden flex-1">
<slot name="second" />
</div>
</div>
</template>

View File

@@ -271,72 +271,59 @@ onUnmounted(() => {
})
</script>
<template>
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
<!-- 监控状态栏 -->
<div v-if="status === 'connected'" class="border-b border-slate-700 bg-slate-900/80">
<div class="flex items-center justify-between px-3 py-1.5 text-xs">
<div class="flex items-center gap-4">
<!-- CPU -->
<div class="flex items-center gap-1">
<Cpu class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">CPU:</span>
<span :class="getUsageColor(monitorData.cpuUsage)">
{{ monitorData.cpuUsage !== undefined ? monitorData.cpuUsage + '%' : '--' }}
</span>
</div>
<!-- 内存 -->
<div class="flex items-center gap-1">
<MemoryStick class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">MEM:</span>
<span :class="getUsageColor(monitorData.memUsage)">
{{ monitorData.memUsage !== undefined ? monitorData.memUsage + '%' : '--' }}
</span>
<span v-if="monitorData.memUsed && monitorData.memTotal" class="text-slate-500 ml-1">
({{ formatBytes(monitorData.memUsed) }}/{{ formatBytes(monitorData.memTotal) }})
</span>
</div>
<!-- 磁盘 -->
<div class="flex items-center gap-1">
<HardDrive class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">DISK:</span>
<span :class="getUsageColor(monitorData.diskUsage, 80, 95)">
{{ monitorData.diskUsage !== undefined ? monitorData.diskUsage + '%' : '--' }}
</span>
</div>
<!-- 负载 -->
<div class="flex items-center gap-1">
<Activity class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">LOAD:</span>
<span :class="getUsageColor(monitorData.load1, monitorData.cpuCores || 4, (monitorData.cpuCores || 4) * 1.5)">
{{ monitorData.load1 !== undefined ? monitorData.load1.toFixed(2) : '--' }}
</span>
</div>
<!-- 运行时间 -->
<div class="flex items-center gap-1">
<Clock class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">UP:</span>
<span class="text-slate-300">
{{ monitorData.uptime || '--' }}
</span>
</div>
</div>
<!-- 折叠按钮 -->
<button
@click="showMonitor = !showMonitor"
class="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
:title="showMonitor ? '隐藏监控栏' : '显示监控栏'"
>
<component :is="showMonitor ? ChevronUp : ChevronDown" class="w-3.5 h-3.5" />
</button>
</div>
</div>
<template>
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
<div class="h-9 border-b border-slate-700 bg-slate-900/85 flex items-center justify-between px-3 text-xs">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :class="status === 'connected' ? 'bg-emerald-400' : status === 'error' ? 'bg-red-400' : 'bg-amber-400'" />
<span class="text-slate-300">{{ status === 'connected' ? 'CONNECTED' : status === 'error' ? 'FAILED' : 'CONNECTING' }}</span>
<span v-if="status === 'connected'" class="text-slate-500">实时监控</span>
</div>
<button
v-if="status === 'connected'"
@click="showMonitor = !showMonitor"
class="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
:title="showMonitor ? '隐藏监控栏' : '显示监控栏'"
>
<component :is="showMonitor ? ChevronUp : ChevronDown" class="w-3.5 h-3.5" />
</button>
</div>
<div v-if="status === 'connected' && showMonitor" class="border-b border-slate-800 bg-slate-900/70 px-3 py-2">
<div class="grid grid-cols-2 lg:grid-cols-5 gap-2 text-xs">
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5">
<Cpu class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-500">CPU</span>
<span class="ml-auto" :class="getUsageColor(monitorData.cpuUsage)">{{ monitorData.cpuUsage !== undefined ? monitorData.cpuUsage + '%' : '--' }}</span>
</div>
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5">
<MemoryStick class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-500">MEM</span>
<span class="ml-auto" :class="getUsageColor(monitorData.memUsage)">{{ monitorData.memUsage !== undefined ? monitorData.memUsage + '%' : '--' }}</span>
</div>
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5">
<HardDrive class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-500">DISK</span>
<span class="ml-auto" :class="getUsageColor(monitorData.diskUsage, 80, 95)">{{ monitorData.diskUsage !== undefined ? monitorData.diskUsage + '%' : '--' }}</span>
</div>
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5">
<Activity class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-500">LOAD</span>
<span class="ml-auto" :class="getUsageColor(monitorData.load1, monitorData.cpuCores || 4, (monitorData.cpuCores || 4) * 1.5)">
{{ monitorData.load1 !== undefined ? monitorData.load1.toFixed(2) : '--' }}
</span>
</div>
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5 col-span-2 lg:col-span-1">
<Clock class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-500">UP</span>
<span class="ml-auto text-slate-300 truncate">{{ monitorData.uptime || '--' }}</span>
</div>
</div>
<p v-if="monitorData.memUsed && monitorData.memTotal" class="mt-1.5 text-[11px] text-slate-500">
内存占用{{ formatBytes(monitorData.memUsed) }} / {{ formatBytes(monitorData.memTotal) }}
</p>
</div>
<div
ref="containerRef"
class="flex-1 min-h-0 p-4 xterm-container"

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { Settings, HelpCircle, Bell, MonitorCog } from 'lucide-vue-next'
</script>
<template>
<div class="h-12 bg-slate-900 border-b border-slate-700 px-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
<MonitorCog class="w-4 h-4 text-white" />
</div>
<span class="text-sm font-semibold text-slate-100">SSH Manager</span>
</div>
<div class="flex items-center gap-1 text-xs text-slate-400">
<button class="px-3 py-1.5 rounded hover:bg-slate-800 hover:text-slate-200 transition-colors">
会话
</button>
<button class="px-3 py-1.5 rounded hover:bg-slate-800 hover:text-slate-200 transition-colors">
工具
</button>
<button class="px-3 py-1.5 rounded hover:bg-slate-800 hover:text-slate-200 transition-colors">
设置
</button>
</div>
</div>
<div class="flex items-center gap-2">
<button
class="p-2 rounded text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
title="通知"
>
<Bell class="w-4 h-4" />
</button>
<button
class="p-2 rounded text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
title="帮助"
>
<HelpCircle class="w-4 h-4" />
</button>
<button
class="p-2 rounded text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
title="设置"
>
<Settings class="w-4 h-4" />
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useWorkspaceStore } from '../stores/workspace'
import SplitPane from './SplitPane.vue'
import TerminalWidget from './TerminalWidget.vue'
import SftpPanel from './SftpPanel.vue'
import { Server } from 'lucide-vue-next'
const workspaceStore = useWorkspaceStore()
const activeConnectionId = computed(() => workspaceStore.activeConnectionId)
const activePanel = computed(() => workspaceStore.activePanel)
function handleRatioChange(ratio: number) {
if (activeConnectionId.value) {
workspaceStore.updateSplitRatio(activeConnectionId.value, ratio)
}
}
</script>
<template>
<div class="h-full bg-slate-950">
<div v-if="!activeConnectionId" class="h-full flex items-center justify-center text-slate-500">
<div class="text-center">
<Server class="w-16 h-16 mx-auto mb-4 text-slate-600" />
<p class="text-lg">请从左侧会话树选择一个连接</p>
</div>
</div>
<SplitPane
v-else-if="activePanel"
direction="vertical"
:initial-ratio="activePanel.splitRatio"
@ratio-change="handleRatioChange"
>
<template #first>
<TerminalWidget
v-if="activePanel.terminalVisible"
:connection-id="activeConnectionId"
:active="true"
/>
</template>
<template #second>
<SftpPanel
v-if="activePanel.sftpVisible"
:connection-id="activeConnectionId"
:active="true"
/>
</template>
</SplitPane>
</div>
</template>