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:
91
frontend/src/components/SessionTree.vue
Normal file
91
frontend/src/components/SessionTree.vue
Normal 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>
|
||||
85
frontend/src/components/SessionTreeNode.vue
Normal file
85
frontend/src/components/SessionTreeNode.vue
Normal 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>
|
||||
315
frontend/src/components/SftpPanel.vue
Normal file
315
frontend/src/components/SftpPanel.vue
Normal 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>
|
||||
93
frontend/src/components/SplitPane.vue
Normal file
93
frontend/src/components/SplitPane.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
49
frontend/src/components/TopToolbar.vue
Normal file
49
frontend/src/components/TopToolbar.vue
Normal 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>
|
||||
53
frontend/src/components/WorkspacePanel.vue
Normal file
53
frontend/src/components/WorkspacePanel.vue
Normal 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>
|
||||
Reference in New Issue
Block a user