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,167 @@
# MobaXterm 风格重构实施状态
## 已完成 ✅
### Phase 1: 基础架构 (100%)
- ✅ 安装依赖 @vueuse/core
- ✅ 创建类型定义
- `frontend/src/types/sessionTree.ts`
- `frontend/src/types/workspace.ts`
- ✅ 实现 Pinia stores
- `frontend/src/stores/sessionTree.ts`
- `frontend/src/stores/workspace.ts`
### Phase 2: 核心组件开发 (100%)
-`SplitPane.vue` - 可拖拽分割面板
-`TopToolbar.vue` - 顶部工具栏(样式占位)
-`SessionTreeNode.vue` - 递归树节点组件
-`SessionTree.vue` - 会话树主组件
-`SftpPanel.vue` - SFTP 面板(props 驱动)
-`WorkspacePanel.vue` - 工作区面板
### Phase 4: 布局集成 (100%)
-`MobaLayout.vue` - 主布局
- ✅ 路由配置更新
- 新增 `/moba` 路由
- 默认首页重定向到 `/moba`
- ✅ 构建测试通过
## 当前状态
### 可用功能
1. **会话树管理**
- 创建文件夹
- 展开/折叠文件夹
- 点击连接打开工作区
- localStorage 持久化
2. **工作区面板**
- 垂直分屏(终端 + SFTP)
- 可拖拽调整分割比例
- 分割比例持久化
3. **终端集成**
- 复用现有 TerminalWidget
- 实时监控面板
- WebSocket 连接
4. **SFTP 功能**
- 文件浏览
- 上传/下载
- 创建文件夹
- 删除文件
### 访问方式
- 新布局: http://localhost:5173/moba
- 旧布局: http://localhost:5173/connections (保留兼容)
## 待实现 🚧
### Phase 3: 拖拽功能 (0%)
- ⏳ 实现 `useTreeDragDrop` composable
- ⏳ 在 SessionTreeNode 中集成拖拽
- ⏳ 拖拽约束和验证
- ⏳ 拖拽视觉反馈
### Phase 5: 数据迁移 (0%)
- ⏳ 从 connections 自动导入
- ⏳ 迁移提示 UI
- ⏳ 新旧布局数据同步
### Phase 6: 优化完善 (0%)
- ⏳ 快捷键支持
- ⏳ 右键菜单
- ⏳ 搜索功能
- ⏳ 响应式设计
- ⏳ 性能优化
## 测试步骤
1. 启动后端服务
```bash
cd backend
go run main.go
```
2. 启动前端服务
```bash
cd frontend
npm run dev
```
3. 访问新布局
```
http://localhost:5173/moba
```
4. 测试功能
- 点击"文件夹"按钮创建文件夹
- 点击连接节点打开工作区
- 拖拽分割条调整终端/SFTP 比例
- 刷新页面验证状态持久化
## 已知问题
1. **拖拽功能未实现**
- 无法拖拽节点重新排序
- 无法拖拽节点到文件夹
2. **会话树初始化**
- 首次使用时会自动从 connections 导入
- 创建默认"我的连接"文件夹
3. **SFTP 功能简化**
- 移除了搜索和隐藏文件功能
- 移除了远程传输功能
- 上传进度显示简化
## 下一步计划
1. **立即实施 (Phase 3)**
- 实现拖拽功能,完成会话树的核心交互
2. **短期优化 (Phase 5-6)**
- 添加快捷键支持
- 实现右键菜单
- 添加搜索功能
3. **长期扩展**
- 多工作区支持
- 会话模板
- 云端同步
## 技术栈
- Vue 3.5.24 (Composition API)
- Pinia 3.0.4 (状态管理)
- Vue Router 5.0.2 (路由)
- Tailwind CSS 3.4.14 (样式)
- @vueuse/core (拖拽工具)
- xterm.js 5.3.0 (终端)
- lucide-vue-next (图标)
## 文件结构
```
frontend/src/
├── types/
│ ├── sessionTree.ts # 会话树类型定义
│ └── workspace.ts # 工作区类型定义
├── stores/
│ ├── sessionTree.ts # 会话树状态管理
│ └── workspace.ts # 工作区状态管理
├── components/
│ ├── SessionTree.vue # 会话树主组件
│ ├── SessionTreeNode.vue # 树节点组件(递归)
│ ├── WorkspacePanel.vue # 工作区面板
│ ├── SplitPane.vue # 分割面板
│ ├── SftpPanel.vue # SFTP 面板
│ └── TopToolbar.vue # 顶部工具栏
└── layouts/
└── MobaLayout.vue # MobaXterm 风格主布局
```
---
**最后更新**: 2026-04-03
**实施进度**: Phase 1-2 完成, Phase 4 完成, Phase 3/5/6 待实施

View File

@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@vueuse/core": "^14.2.1",
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.10.0",
"axios": "^1.13.4",
@@ -1004,6 +1005,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
@@ -1245,6 +1252,44 @@
}
}
},
"node_modules/@vueuse/core": {
"version": "14.2.1",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.2.1.tgz",
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.1",
"@vueuse/shared": "14.2.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.2.1",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.2.1.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.2.1",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.2.1.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@xterm/addon-attach": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.12.0.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.2.1",
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.10.0",
"axios": "^1.13.4",

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>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useConnectionsStore } from '../stores/connections'
import { useSftpTabsStore } from '../stores/sftpTabs'
import { useTerminalTabsStore } from '../stores/terminalTabs'
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen } from 'lucide-vue-next'
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen, MonitorCog, Clock3 } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
@@ -14,14 +14,31 @@ const authStore = useAuthStore()
const connectionsStore = useConnectionsStore()
const sftpTabsStore = useSftpTabsStore()
const tabsStore = useTerminalTabsStore()
const sidebarOpen = ref(false)
const now = ref(new Date())
let clockTimer = 0
const terminalTabs = computed(() => tabsStore.tabs)
const sftpTabs = computed(() => sftpTabsStore.tabs)
const showTerminalWorkspace = computed(() => route.path === '/terminal')
const keepTerminalWorkspaceMounted = computed(() => showTerminalWorkspace.value || terminalTabs.value.length > 0)
connectionsStore.fetchConnections().catch(() => {})
const currentSectionTitle = computed(() => {
if (route.path.startsWith('/connections')) return 'Session Manager'
if (route.path.startsWith('/terminal')) return 'Terminal Workspace'
if (route.path.startsWith('/sftp/')) return 'SFTP Browser'
if (route.path.startsWith('/transfers')) return 'Transfer Queue'
return 'SSH Manager'
})
const nowText = computed(() => {
return now.value.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
})
function closeSidebar() {
sidebarOpen.value = false
@@ -66,101 +83,145 @@ function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
router.push('/connections')
}
function handleLogout() {
authStore.logout()
router.push('/login')
}
onMounted(() => {
connectionsStore.fetchConnections().catch(() => {})
clockTimer = window.setInterval(() => {
now.value = new Date()
}, 1000)
})
onUnmounted(() => {
clearInterval(clockTimer)
})
</script>
<template>
<div class="flex h-screen bg-slate-900">
<div class="flex h-screen bg-slate-950 text-slate-100">
<button
@click="sidebarOpen = !sidebarOpen"
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 cursor-pointer"
class="lg:hidden fixed top-3 left-3 z-40 p-2 rounded-md bg-slate-800 border border-slate-600 text-slate-200 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-400 cursor-pointer"
aria-label="切换侧边栏"
>
<Menu v-if="!sidebarOpen" class="w-6 h-6" aria-hidden="true" />
<X v-else class="w-6 h-6" aria-hidden="true" />
<Menu v-if="!sidebarOpen" class="w-5 h-5" aria-hidden="true" />
<X v-else class="w-5 h-5" aria-hidden="true" />
</button>
<aside
:class="[
'w-64 bg-slate-800 border-r border-slate-700 flex flex-col transition-transform duration-200 z-20',
'w-72 border-r border-slate-700/80 flex flex-col transition-transform duration-200 z-30 bg-slate-900/95 backdrop-blur-sm',
'fixed lg:static inset-y-0 left-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
]"
>
<div class="p-4 border-b border-slate-700">
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
</div>
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4 overflow-y-auto">
<RouterLink
to="/connections"
@click="closeSidebar"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
aria-label="连接列表"
>
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
<span>连接列表</span>
</RouterLink>
<RouterLink
to="/transfers"
@click="closeSidebar"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/transfers' }"
aria-label="传输"
>
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
<span>传输</span>
</RouterLink>
<!-- 终端标签区域 -->
<div v-if="terminalTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<Terminal class="w-4 h-4" aria-hidden="true" />
<span>终端</span>
<div class="px-4 py-3 border-b border-slate-700/80">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-md bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
<MonitorCog class="w-5 h-5 text-white" aria-hidden="true" />
</div>
<div class="space-y-1 mt-2">
<button
v-for="tab in terminalTabs"
:key="tab.id"
@click="handleTabClick(tab.id)"
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset group"
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === '/terminal' }"
<div>
<h1 class="text-sm font-semibold tracking-wide text-slate-100">SSH Manager</h1>
<p class="text-xs text-slate-400">{{ authStore.displayName || authStore.username }}</p>
</div>
</div>
</div>
<nav class="flex-1 px-3 py-3 overflow-y-auto space-y-4 pt-16 lg:pt-3">
<div>
<p class="px-2 text-[11px] uppercase tracking-wider text-slate-500 mb-2">工作区</p>
<div class="space-y-1">
<RouterLink
to="/connections"
@click="closeSidebar"
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path === '/connections' }"
aria-label="连接列表"
>
<span class="truncate text-sm">{{ tab.title }}</span>
<button
@click="(e) => handleTabClose(tab.id, e)"
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-all duration-200 flex-shrink-0"
aria-label="关闭标签"
>
<X class="w-3 h-3" aria-hidden="true" />
</button>
</button>
<Server class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Connections</span>
</RouterLink>
<RouterLink
to="/transfers"
@click="closeSidebar"
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path === '/transfers' }"
aria-label="传输"
>
<ArrowLeftRight class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Transfers</span>
</RouterLink>
</div>
</div>
<div v-if="sftpTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<FolderOpen class="w-4 h-4" aria-hidden="true" />
<span>文件</span>
<div v-if="terminalTabs.length > 0" class="pt-3 border-t border-slate-700/70">
<div class="flex items-center justify-between px-2 mb-2">
<p class="text-[11px] uppercase tracking-wider text-slate-500">Terminal Sessions</p>
<span class="text-[11px] text-slate-500">{{ terminalTabs.length }}</span>
</div>
<div class="space-y-1 mt-2">
<div class="space-y-1">
<div
v-for="tab in terminalTabs"
:key="tab.id"
class="group flex items-center gap-2 rounded-md border border-transparent px-2 py-2 hover:bg-slate-800 transition-colors"
:class="{ 'bg-slate-800 border-cyan-500/30': tab.active && route.path === '/terminal' }"
>
<button
type="button"
@click="handleTabClick(tab.id)"
class="flex-1 min-w-0 text-left cursor-pointer focus:outline-none"
:aria-label="`切换终端会话 ${tab.title}`"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-cyan-400' : 'bg-slate-600'" />
<Terminal class="w-3.5 h-3.5 text-slate-400" aria-hidden="true" />
<span class="truncate text-sm text-slate-200">{{ tab.title }}</span>
</div>
</button>
<button
type="button"
@click="(e) => handleTabClose(tab.id, e)"
class="p-1 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200 transition-opacity duration-200 cursor-pointer"
aria-label="关闭终端标签"
>
<X class="w-3 h-3" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div v-if="sftpTabs.length > 0" class="pt-3 border-t border-slate-700/70">
<div class="flex items-center justify-between px-2 mb-2">
<p class="text-[11px] uppercase tracking-wider text-slate-500">File Sessions</p>
<span class="text-[11px] text-slate-500">{{ sftpTabs.length }}</span>
</div>
<div class="space-y-1">
<div
v-for="tab in sftpTabs"
:key="tab.id"
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 min-h-[44px] group focus-within:outline-none focus-within:ring-2 focus-within:ring-cyan-500 focus-within:ring-inset"
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === `/sftp/${tab.connectionId}` }"
class="group flex items-center gap-2 rounded-md border border-transparent px-2 py-2 hover:bg-slate-800 transition-colors"
:class="{ 'bg-slate-800 border-cyan-500/30': tab.active && route.path === `/sftp/${tab.connectionId}` }"
>
<button
type="button"
@click="handleSftpTabClick(tab.id, tab.connectionId)"
class="flex-1 min-w-0 text-left cursor-pointer focus:outline-none"
:aria-label="`打开文件标签 ${tab.title}`"
:aria-label="`切换文件会话 ${tab.title}`"
>
<span class="truncate text-sm block">{{ tab.title }}</span>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-emerald-400' : 'bg-slate-600'" />
<FolderOpen class="w-3.5 h-3.5 text-slate-400" aria-hidden="true" />
<span class="truncate text-sm text-slate-200">{{ tab.title }}</span>
</div>
</button>
<button
type="button"
@click="(e) => handleSftpTabClose(tab.id, tab.connectionId, e)"
class="p-1 rounded opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100 hover:bg-slate-600 transition-opacity duration-200 transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-cyan-500"
class="p-1 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200 transition-opacity duration-200 cursor-pointer"
aria-label="关闭文件标签"
>
<X class="w-3 h-3" aria-hidden="true" />
@@ -169,35 +230,54 @@ function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
</div>
</div>
</nav>
<div class="p-4 border-t border-slate-700">
<div class="p-3 border-t border-slate-700/80">
<button
@click="authStore.logout(); $router.push('/login')"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
@click="handleLogout"
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
aria-label="退出登录"
>
<LogOut class="w-5 h-5" aria-hidden="true" />
<LogOut class="w-4 h-4" aria-hidden="true" />
<span>退出登录</span>
</button>
</div>
</aside>
<div
v-if="sidebarOpen"
class="lg:hidden fixed inset-0 bg-black/50 z-10"
class="lg:hidden fixed inset-0 bg-black/55 z-20"
aria-hidden="true"
@click="sidebarOpen = false"
/>
<main class="flex-1 overflow-auto min-w-0">
<div v-if="keepTerminalWorkspaceMounted" v-show="showTerminalWorkspace" class="h-full">
<TerminalWorkspaceView :visible="showTerminalWorkspace" />
<main class="flex-1 min-w-0 overflow-hidden flex flex-col">
<header class="h-12 border-b border-slate-700/80 bg-slate-900/70 backdrop-blur px-4 md:px-5 flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<h2 class="text-sm md:text-base font-medium text-slate-100 truncate">{{ currentSectionTitle }}</h2>
<div class="hidden md:flex items-center gap-2 text-xs text-slate-400">
<span class="px-2 py-1 rounded bg-slate-800 border border-slate-700">TTY {{ terminalTabs.length }}</span>
<span class="px-2 py-1 rounded bg-slate-800 border border-slate-700">SFTP {{ sftpTabs.length }}</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs text-slate-400">
<Clock3 class="w-3.5 h-3.5" aria-hidden="true" />
<span class="tabular-nums">{{ nowText }}</span>
</div>
</header>
<div class="flex-1 min-h-0 overflow-auto">
<div v-if="keepTerminalWorkspaceMounted" v-show="showTerminalWorkspace" class="h-full">
<TerminalWorkspaceView :visible="showTerminalWorkspace" />
</div>
<RouterView v-slot="{ Component, route }">
<template v-if="!showTerminalWorkspace">
<keep-alive :max="10" v-if="route.meta.keepAlive">
<component :is="Component" :key="route.params.id" />
</keep-alive>
<component :is="Component" :key="route.fullPath" v-else />
</template>
</RouterView>
</div>
<RouterView v-slot="{ Component, route }">
<template v-if="!showTerminalWorkspace">
<keep-alive :max="10" v-if="route.meta.keepAlive">
<component :is="Component" :key="route.params.id" />
</keep-alive>
<component :is="Component" :key="route.fullPath" v-else />
</template>
</RouterView>
</main>
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useSessionTreeStore } from '../stores/sessionTree'
import { useWorkspaceStore } from '../stores/workspace'
import TopToolbar from '../components/TopToolbar.vue'
import SessionTree from '../components/SessionTree.vue'
import WorkspacePanel from '../components/WorkspacePanel.vue'
const treeStore = useSessionTreeStore()
const workspaceStore = useWorkspaceStore()
onMounted(() => {
treeStore.restore()
workspaceStore.restore()
if (treeStore.nodes.length === 0) {
treeStore.initFromConnections()
}
})
</script>
<template>
<div class="h-screen flex flex-col bg-slate-950 text-slate-100">
<TopToolbar />
<div class="flex-1 min-h-0 flex">
<div class="w-72 flex-shrink-0">
<SessionTree />
</div>
<div class="flex-1 min-w-0">
<WorkspacePanel />
</div>
</div>
</div>
</template>

View File

@@ -9,21 +9,27 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/LoginView.vue'),
meta: { public: true },
},
{
path: '/moba',
name: 'MobaLayout',
component: () => import('../layouts/MobaLayout.vue'),
meta: { requiresAuth: true },
},
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
redirect: '/connections',
},
{
path: 'transfers',
name: 'Transfers',
component: () => import('../views/TransfersView.vue'),
},
{
path: '',
name: 'Home',
redirect: '/moba',
},
{
path: 'transfers',
name: 'Transfers',
component: () => import('../views/TransfersView.vue'),
},
{
path: 'connections',
name: 'Connections',
@@ -38,16 +44,16 @@ const routes: RouteRecordRaw[] = [
path: 'terminal/:id',
name: 'Terminal',
component: () => import('../views/TerminalView.vue'),
},
},
{
path: 'sftp/:id',
name: 'Sftp',
component: () => import('../views/SftpView.vue'),
meta: { keepAlive: true },
},
],
},
]
},
],
},
]
const router = createRouter({
history: createWebHistory(),
@@ -56,12 +62,12 @@ const router = createRouter({
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.public) {
if (authStore.isAuthenticated && to.path === '/login') {
next('/connections')
} else {
next()
}
if (to.meta.public) {
if (authStore.isAuthenticated && to.path === '/login') {
next('/connections')
} else {
next()
}
return
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {

View File

@@ -0,0 +1,159 @@
import { defineStore } from 'pinia'
import type { SessionTreeNode, SessionTreeState } from '../types/sessionTree'
import { useConnectionsStore } from './connections'
const STORAGE_KEY = 'ssh-manager.session-tree'
export const useSessionTreeStore = defineStore('sessionTree', {
state: (): SessionTreeState => ({
nodes: [],
selectedNodeId: null,
}),
getters: {
rootNodes: (state) => {
return state.nodes
.filter(n => n.parentId === null)
.sort((a, b) => a.order - b.order)
},
getChildren: (state) => (parentId: string) => {
return state.nodes
.filter(n => n.parentId === parentId)
.sort((a, b) => a.order - b.order)
},
getNodePath: (state) => (nodeId: string): SessionTreeNode[] => {
const path: SessionTreeNode[] = []
let current = state.nodes.find(n => n.id === nodeId)
while (current) {
path.unshift(current)
current = current.parentId
? state.nodes.find(n => n.id === current!.parentId)
: undefined
}
return path
},
},
actions: {
createFolder(name: string, parentId: string | null = null) {
const node: SessionTreeNode = {
id: crypto.randomUUID(),
type: 'folder',
name,
parentId,
order: this.getMaxOrder(parentId) + 1,
expanded: true,
createdAt: Date.now(),
updatedAt: Date.now(),
}
this.nodes.push(node)
this.persist()
return node
},
createConnectionNode(connectionId: number, name: string, parentId: string | null = null) {
const node: SessionTreeNode = {
id: crypto.randomUUID(),
type: 'connection',
name,
parentId,
order: this.getMaxOrder(parentId) + 1,
connectionId,
createdAt: Date.now(),
updatedAt: Date.now(),
}
this.nodes.push(node)
this.persist()
return node
},
moveNode(nodeId: string, newParentId: string | null, newOrder: number) {
const node = this.nodes.find(n => n.id === nodeId)
if (!node) return
node.parentId = newParentId
node.order = newOrder
node.updatedAt = Date.now()
this.reorderSiblings(newParentId)
this.persist()
},
deleteNode(nodeId: string) {
const children = this.getChildren(nodeId)
children.forEach(child => this.deleteNode(child.id))
this.nodes = this.nodes.filter(n => n.id !== nodeId)
this.persist()
},
renameNode(nodeId: string, newName: string) {
const node = this.nodes.find(n => n.id === nodeId)
if (node) {
node.name = newName
node.updatedAt = Date.now()
this.persist()
}
},
toggleExpanded(nodeId: string) {
const node = this.nodes.find(n => n.id === nodeId)
if (node && node.type === 'folder') {
node.expanded = !node.expanded
this.persist()
}
},
selectNode(nodeId: string | null) {
this.selectedNodeId = nodeId
},
getMaxOrder(parentId: string | null): number {
const siblings = this.nodes.filter(n => n.parentId === parentId)
return siblings.length > 0
? Math.max(...siblings.map(n => n.order))
: 0
},
reorderSiblings(parentId: string | null) {
const siblings = this.nodes
.filter(n => n.parentId === parentId)
.sort((a, b) => a.order - b.order)
siblings.forEach((node, index) => {
node.order = index
})
},
persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.$state))
},
restore() {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
try {
const data = JSON.parse(raw)
this.$patch(data)
} catch (e) {
console.error('Failed to restore session tree:', e)
}
}
},
async initFromConnections() {
const connectionsStore = useConnectionsStore()
await connectionsStore.fetchConnections()
if (this.nodes.length > 0) return
const defaultFolder = this.createFolder('我的连接')
connectionsStore.connections.forEach(conn => {
this.createConnectionNode(conn.id, conn.name, defaultFolder.id)
})
},
},
})

View File

@@ -0,0 +1,107 @@
import { defineStore } from 'pinia'
import type { WorkspaceState, WorkspacePanelState } from '../types/workspace'
const STORAGE_KEY = 'ssh-manager.workspace'
export const useWorkspaceStore = defineStore('workspace', {
state: (): WorkspaceState => ({
activeConnectionId: null,
panels: {},
}),
getters: {
activePanel: (state): WorkspacePanelState | null => {
return state.activeConnectionId
? state.panels[state.activeConnectionId] || null
: null
},
},
actions: {
openPanel(connectionId: number) {
if (!this.panels[connectionId]) {
this.panels[connectionId] = {
connectionId,
splitRatio: 0.5,
terminalVisible: true,
sftpVisible: true,
currentPath: '.',
selectedFiles: [],
lastActiveAt: Date.now(),
}
}
this.activeConnectionId = connectionId
this.panels[connectionId].lastActiveAt = Date.now()
this.persist()
},
closePanel(connectionId: number) {
delete this.panels[connectionId]
if (this.activeConnectionId === connectionId) {
const sorted = Object.values(this.panels)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt)
this.activeConnectionId = sorted[0]?.connectionId || null
}
this.persist()
},
updateSplitRatio(connectionId: number, ratio: number) {
const panel = this.panels[connectionId]
if (panel) {
panel.splitRatio = Math.max(0.2, Math.min(0.8, ratio))
this.persist()
}
},
toggleTerminal(connectionId: number) {
const panel = this.panels[connectionId]
if (panel) {
panel.terminalVisible = !panel.terminalVisible
this.persist()
}
},
toggleSftp(connectionId: number) {
const panel = this.panels[connectionId]
if (panel) {
panel.sftpVisible = !panel.sftpVisible
this.persist()
}
},
updateSftpPath(connectionId: number, path: string) {
const panel = this.panels[connectionId]
if (panel) {
panel.currentPath = path
this.persist()
}
},
updateSelectedFiles(connectionId: number, files: string[]) {
const panel = this.panels[connectionId]
if (panel) {
panel.selectedFiles = files
this.persist()
}
},
persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.$state))
},
restore() {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
try {
const data = JSON.parse(raw)
this.$patch(data)
} catch (e) {
console.error('Failed to restore workspace:', e)
}
}
},
},
})

View File

@@ -0,0 +1,18 @@
export type SessionTreeNodeType = 'folder' | 'connection'
export interface SessionTreeNode {
id: string
type: SessionTreeNodeType
name: string
parentId: string | null
order: number
connectionId?: number
expanded?: boolean
createdAt: number
updatedAt: number
}
export interface SessionTreeState {
nodes: SessionTreeNode[]
selectedNodeId: string | null
}

View File

@@ -0,0 +1,14 @@
export interface WorkspacePanelState {
connectionId: number
splitRatio: number
terminalVisible: boolean
sftpVisible: boolean
currentPath: string
selectedFiles: string[]
lastActiveAt: number
}
export interface WorkspaceState {
activeConnectionId: number | null
panels: Record<number, WorkspacePanelState>
}

View File

@@ -1,250 +1,419 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import { useSftpTabsStore } from '../stores/sftpTabs'
import { useTerminalTabsStore } from '../stores/terminalTabs'
import type { Connection, ConnectionCreateRequest } from '../api/connections'
import ConnectionForm from '../components/ConnectionForm.vue'
import {
Server,
Plus,
Terminal,
FolderOpen,
Pencil,
Trash2,
Key,
Lock,
Search,
X,
} from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
const store = useConnectionsStore()
const tabsStore = useTerminalTabsStore()
const sftpTabsStore = useSftpTabsStore()
const showForm = ref(false)
const editingConn = ref<Connection | null>(null)
const searchQuery = ref('')
let searchDebounceTimer = 0
const keyword = computed(() => route.query.q as string || '')
const filteredConnections = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) {
return store.connections
}
return store.connections.filter((conn) => {
const fields = [
conn.name,
conn.host,
conn.username,
String(conn.port),
]
return fields.some((field) => field.toLowerCase().includes(q))
})
})
const updateSearchParam = (value: string) => {
router.push({ query: { ...route.query, q: value } })
}
onMounted(() => {
searchQuery.value = keyword.value
store.fetchConnections()
})
watch(searchQuery, (val) => {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
updateSearchParam(val)
}, 300)
})
function openCreate() {
editingConn.value = null
showForm.value = true
}
function openEdit(conn: Connection) {
editingConn.value = conn
showForm.value = true
}
function closeForm() {
showForm.value = false
editingConn.value = null
}
async function handleSave(data: ConnectionCreateRequest) {
if (editingConn.value) {
await store.updateConnection(editingConn.value.id, data)
} else {
await store.createConnection(data)
}
closeForm()
}
async function handleDelete(conn: Connection) {
if (!confirm(`确定删除连接「${conn.name}」?`)) return
await store.deleteConnection(conn.id)
}
function openTerminal(conn: Connection) {
tabsStore.openTab(conn)
router.push('/terminal')
}
function openSftp(conn: Connection) {
sftpTabsStore.openOrFocus(conn)
router.push(`/sftp/${conn.id}`)
}
function clearSearch() {
searchQuery.value = ''
updateSearchParam('')
}
function highlightMatch(text: string): string {
const q = searchQuery.value.trim()
if (!q) return text
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(escaped, 'gi')
return text.replace(regex, (match) => `<span class="text-cyan-300 font-semibold">${match}</span>`)
}
</script>
<template>
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-slate-100">连接列表</h2>
<button
@click="openCreate"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 cursor-pointer"
aria-label="添加连接"
>
<Plus class="w-5 h-5" aria-hidden="true" />
添加连接
</button>
</div>
<div class="relative mb-6">
<Search class="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" aria-hidden="true" />
<input
v-model="searchQuery"
type="text"
placeholder="搜索名称、主机、用户名或端口"
class="w-full rounded-xl border border-slate-700 bg-slate-800/70 py-3 pl-10 pr-11 text-slate-100 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
aria-label="搜索连接"
/>
<button
v-if="searchQuery"
@click="clearSearch"
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="清空搜索"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div v-if="store.connections.length === 0" class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700">
<Server class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
<p class="text-slate-400 mb-4">暂无连接</p>
<button
@click="openCreate"
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white transition-colors duration-200 cursor-pointer"
>
添加第一个连接
</button>
</div>
<div
v-else-if="filteredConnections.length === 0"
class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700"
>
<Search class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
<p class="text-slate-300 mb-2">未找到匹配的连接</p>
<p class="text-sm text-slate-500 mb-4">试试搜索名称主机用户名或端口</p>
<button
@click="clearSearch"
class="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors duration-200 cursor-pointer"
>
清空搜索
</button>
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="conn in filteredConnections"
:key="conn.id"
class="bg-slate-800 rounded-xl border border-slate-700 p-4 hover:border-slate-600 transition-colors duration-200"
>
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<div class="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
<Server class="w-5 h-5 text-cyan-400" aria-hidden="true" />
</div>
<div>
<h3 class="font-medium text-slate-100" v-html="highlightMatch(conn.name)"></h3>
<p class="text-sm text-slate-400" v-html="highlightMatch(`${conn.username}@${conn.host}:${conn.port}`)"></p>
</div>
</div>
<div class="flex items-center gap-1">
<button
@click="openEdit(conn)"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="编辑"
>
<Pencil class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="handleDelete(conn)"
class="p-2 rounded-lg text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
aria-label="删除"
>
<Trash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
<div class="flex items-center gap-2 mb-3">
<component
:is="conn.authType === 'PRIVATE_KEY' ? Key : Lock"
class="w-4 h-4 text-slate-500"
aria-hidden="true"
/>
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
</div>
<div class="flex gap-2">
<button
@click="openTerminal(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<Terminal class="w-4 h-4" aria-hidden="true" />
终端
</button>
<button
@click="openSftp(conn)"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
>
<FolderOpen class="w-4 h-4" aria-hidden="true" />
文件
</button>
</div>
</div>
</div>
<ConnectionForm
v-if="showForm"
:connection="editingConn"
:on-save="handleSave"
@close="closeForm"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import { useSftpTabsStore } from '../stores/sftpTabs'
import { useTerminalTabsStore } from '../stores/terminalTabs'
import type { Connection, ConnectionCreateRequest } from '../api/connections'
import ConnectionForm from '../components/ConnectionForm.vue'
import {
Server,
Plus,
Terminal,
FolderOpen,
Pencil,
Trash2,
Key,
Lock,
Search,
X,
Activity,
ShieldCheck,
Star,
} from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
const store = useConnectionsStore()
const tabsStore = useTerminalTabsStore()
const sftpTabsStore = useSftpTabsStore()
const showForm = ref(false)
const editingConn = ref<Connection | null>(null)
const searchQuery = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
const FAVORITES_STORAGE_KEY = 'ssh-manager.favorite-connections'
const favoriteConnectionIds = ref<number[]>([])
let searchDebounceTimer = 0
const keyword = computed(() => route.query.q as string || '')
const filteredConnections = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) {
return store.connections
}
return store.connections.filter((conn) => {
const fields = [
conn.name,
conn.host,
conn.username,
String(conn.port),
]
return fields.some((field) => field.toLowerCase().includes(q))
})
})
const favoriteConnectionIdSet = computed(() => new Set(favoriteConnectionIds.value))
const groupedConnections = computed(() => {
const favorites: Connection[] = []
const others: Connection[] = []
filteredConnections.value.forEach((conn) => {
if (favoriteConnectionIdSet.value.has(conn.id)) {
favorites.push(conn)
return
}
others.push(conn)
})
return [
{ key: 'favorites', label: '收藏置顶', items: favorites },
{ key: 'others', label: '全部连接', items: others },
]
})
const passwordCount = computed(() => store.connections.filter((conn) => conn.authType === 'PASSWORD').length)
const keyCount = computed(() => store.connections.filter((conn) => conn.authType === 'PRIVATE_KEY').length)
const updateSearchParam = (value: string) => {
router.push({ query: { ...route.query, q: value } })
}
onMounted(() => {
searchQuery.value = keyword.value
loadFavoriteConnections()
store.fetchConnections()
window.addEventListener('keydown', handleGlobalShortcuts)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalShortcuts)
})
watch(searchQuery, (val) => {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
updateSearchParam(val)
}, 300)
})
watch(
() => store.connections.map((conn) => conn.id),
(ids) => {
const idSet = new Set(ids)
const next = favoriteConnectionIds.value.filter((id) => idSet.has(id))
if (next.length !== favoriteConnectionIds.value.length) {
favoriteConnectionIds.value = next
saveFavoriteConnections()
}
}
)
function openCreate() {
editingConn.value = null
showForm.value = true
}
function loadFavoriteConnections() {
try {
const raw = localStorage.getItem(FAVORITES_STORAGE_KEY)
if (!raw) return
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
favoriteConnectionIds.value = parsed
.map((id) => Number(id))
.filter((id) => Number.isInteger(id) && id > 0)
}
} catch {
favoriteConnectionIds.value = []
}
}
function saveFavoriteConnections() {
localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(favoriteConnectionIds.value))
}
function isFavorite(connId: number) {
return favoriteConnectionIdSet.value.has(connId)
}
function toggleFavorite(connId: number) {
if (isFavorite(connId)) {
favoriteConnectionIds.value = favoriteConnectionIds.value.filter((id) => id !== connId)
} else {
favoriteConnectionIds.value = [...favoriteConnectionIds.value, connId]
}
saveFavoriteConnections()
}
function openEdit(conn: Connection) {
editingConn.value = conn
showForm.value = true
}
function closeForm() {
showForm.value = false
editingConn.value = null
}
async function handleSave(data: ConnectionCreateRequest) {
if (editingConn.value) {
await store.updateConnection(editingConn.value.id, data)
} else {
await store.createConnection(data)
}
closeForm()
}
async function handleDelete(conn: Connection) {
if (!confirm(`确定删除连接「${conn.name}」?`)) return
await store.deleteConnection(conn.id)
}
function openTerminal(conn: Connection) {
tabsStore.openTab(conn)
router.push('/terminal')
}
function openSftp(conn: Connection) {
sftpTabsStore.openOrFocus(conn)
router.push(`/sftp/${conn.id}`)
}
function clearSearch() {
searchQuery.value = ''
updateSearchParam('')
}
function focusSearch() {
searchInputRef.value?.focus()
searchInputRef.value?.select()
}
function handleGlobalShortcuts(event: KeyboardEvent) {
if (showForm.value) return
if (!(event.ctrlKey || event.metaKey)) return
const key = event.key.toLowerCase()
if (key === 'f') {
event.preventDefault()
focusSearch()
return
}
if (key === 'n') {
event.preventDefault()
openCreate()
}
}
function highlightMatch(text: string): string {
const q = searchQuery.value.trim()
if (!q) return text
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(escaped, 'gi')
return text.replace(regex, (match) => `<span class="text-cyan-300 font-semibold">${match}</span>`)
}
</script>
<template>
<div class="h-full p-4 md:p-6 space-y-4">
<section class="rounded-xl border border-slate-700 bg-slate-900/70 backdrop-blur-sm">
<div class="px-4 py-3 border-b border-slate-700 flex flex-col lg:flex-row lg:items-center gap-3 lg:justify-between">
<div>
<h2 class="text-lg font-semibold text-slate-100">Connections</h2>
<p class="text-xs text-slate-400 mt-1">Session 树模式快速打开终端与文件会话Ctrl/Cmd+F 搜索Ctrl/Cmd+N 新建</p>
</div>
<div class="flex w-full lg:w-auto items-center gap-2">
<div class="relative flex-1 lg:w-80">
<Search class="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" aria-hidden="true" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
placeholder="搜索名称、主机、用户名或端口"
class="w-full rounded-lg border border-slate-600 bg-slate-950/80 py-2.5 pl-10 pr-10 text-sm text-slate-100 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
aria-label="搜索连接"
/>
<button
v-if="searchQuery"
@click="clearSearch"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-500 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="清空搜索"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<button
@click="openCreate"
class="shrink-0 min-h-[44px] px-3.5 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-medium transition-colors duration-200 flex items-center gap-2 cursor-pointer"
aria-label="添加连接"
>
<Plus class="w-4 h-4" aria-hidden="true" />
新建
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 p-3 border-b border-slate-700/70">
<div class="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-2.5">
<p class="text-xs text-slate-500">总连接数</p>
<p class="mt-1 text-xl font-semibold text-slate-100">{{ store.connections.length }}</p>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-2.5">
<p class="text-xs text-slate-500">密码认证</p>
<p class="mt-1 text-xl font-semibold text-amber-300">{{ passwordCount }}</p>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-2.5">
<p class="text-xs text-slate-500">私钥认证</p>
<p class="mt-1 text-xl font-semibold text-cyan-300">{{ keyCount }}</p>
</div>
</div>
<div v-if="store.connections.length === 0" class="px-4 py-14 text-center">
<Server class="w-14 h-14 text-slate-500 mx-auto mb-4" aria-hidden="true" />
<p class="text-slate-300 mb-2">暂无连接配置</p>
<p class="text-sm text-slate-500 mb-4">建议先创建一条常用主机作为默认会话入口</p>
<button
@click="openCreate"
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white text-sm transition-colors duration-200 cursor-pointer"
>
添加第一个连接
</button>
</div>
<div v-else-if="filteredConnections.length === 0" class="px-4 py-14 text-center">
<Search class="w-14 h-14 text-slate-500 mx-auto mb-4" aria-hidden="true" />
<p class="text-slate-300 mb-1">没有匹配结果</p>
<p class="text-sm text-slate-500 mb-4">可尝试主机名用户名端口组合搜索</p>
<button
@click="clearSearch"
class="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm transition-colors duration-200 cursor-pointer"
>
清空搜索
</button>
</div>
<div v-else class="overflow-x-auto">
<div class="min-w-[860px]">
<div class="grid grid-cols-[2.3fr_1.5fr_1fr_1.6fr] gap-3 px-4 py-2.5 text-xs uppercase tracking-wider text-slate-500 border-b border-slate-700 bg-slate-900/80">
<span>Session</span>
<span>地址</span>
<span>认证</span>
<span class="text-right pr-1">操作</span>
</div>
<template v-for="group in groupedConnections" :key="group.key">
<div
v-if="group.items.length > 0"
class="px-4 py-2 text-[11px] font-semibold tracking-wide uppercase text-slate-500 bg-slate-900/65 border-b border-slate-800"
>
{{ group.label }} · {{ group.items.length }}
</div>
<div
v-for="conn in group.items"
:key="conn.id"
class="grid grid-cols-[2.3fr_1.5fr_1fr_1.6fr] gap-3 px-4 py-3 border-b border-slate-800 hover:bg-slate-800/45 transition-colors"
@dblclick="openTerminal(conn)"
>
<div class="min-w-0 flex items-center gap-3">
<div class="w-9 h-9 rounded-md border border-slate-700 bg-slate-800 flex items-center justify-center">
<Activity class="w-4 h-4 text-cyan-300" aria-hidden="true" />
</div>
<div class="min-w-0">
<p class="text-sm text-slate-100 truncate flex items-center gap-1.5">
<Star
v-if="isFavorite(conn.id)"
class="w-3.5 h-3.5 text-amber-300 fill-amber-300/70 flex-shrink-0"
aria-hidden="true"
/>
<span v-html="highlightMatch(conn.name)"></span>
</p>
<p class="text-xs text-slate-500 truncate" v-html="highlightMatch(conn.username)"></p>
</div>
</div>
<div class="min-w-0 self-center text-sm text-slate-300" v-html="highlightMatch(`${conn.host}:${conn.port}`)"></div>
<div class="self-center">
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs"
:class="conn.authType === 'PRIVATE_KEY' ? 'border-cyan-700/60 bg-cyan-900/25 text-cyan-300' : 'border-amber-700/60 bg-amber-900/25 text-amber-300'"
>
<component :is="conn.authType === 'PRIVATE_KEY' ? Key : Lock" class="w-3.5 h-3.5" aria-hidden="true" />
{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}
</span>
</div>
<div class="self-center flex justify-end items-center gap-1">
<button
@click="toggleFavorite(conn.id)"
class="min-h-[40px] px-2 rounded-md text-slate-400 hover:bg-amber-900/35 hover:text-amber-300 transition-colors duration-200 cursor-pointer"
:aria-label="isFavorite(conn.id) ? '取消收藏' : '收藏'"
:title="isFavorite(conn.id) ? '取消收藏' : '收藏'"
>
<Star
class="w-4 h-4"
:class="isFavorite(conn.id) ? 'fill-amber-300/70 text-amber-300' : ''"
aria-hidden="true"
/>
</button>
<button
@click="openTerminal(conn)"
class="min-h-[40px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-200 hover:border-cyan-500/50 hover:text-cyan-300 transition-colors duration-200 cursor-pointer flex items-center gap-1.5 text-xs"
aria-label="打开终端"
>
<Terminal class="w-3.5 h-3.5" aria-hidden="true" />
终端
</button>
<button
@click="openSftp(conn)"
class="min-h-[40px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-200 hover:border-emerald-500/50 hover:text-emerald-300 transition-colors duration-200 cursor-pointer flex items-center gap-1.5 text-xs"
aria-label="打开文件"
>
<FolderOpen class="w-3.5 h-3.5" aria-hidden="true" />
文件
</button>
<button
@click="openEdit(conn)"
class="min-h-[40px] px-2 rounded-md text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="编辑"
>
<Pencil class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="handleDelete(conn)"
class="min-h-[40px] px-2 rounded-md text-slate-400 hover:bg-red-900/45 hover:text-red-400 transition-colors duration-200 cursor-pointer"
aria-label="删除"
>
<Trash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</template>
</div>
</div>
</section>
<section class="rounded-xl border border-slate-700 bg-slate-900/60 p-3 md:p-4">
<div class="flex items-start gap-2 text-xs text-slate-400">
<ShieldCheck class="w-4 h-4 mt-0.5 text-cyan-400" aria-hidden="true" />
<p>建议把常用主机命名为业务前缀例如 <code class="text-slate-200">prod-api-01</code>会话切换与快速搜索更高效</p>
</div>
</section>
<ConnectionForm
v-if="showForm"
:connection="editingConn"
:on-save="handleSave"
@close="closeForm"
/>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
defineOptions({ name: 'SftpView' })
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'vue-toast-notification'
import { useConnectionsStore } from '../stores/connections'
@@ -40,8 +40,12 @@ const files = ref<SftpFileInfo[]>([])
const loading = ref(false)
const error = ref('')
const uploading = ref(false)
const selectedFile = ref<string | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const pathInputRef = ref<HTMLInputElement | null>(null)
const pathDraft = ref('.')
const UP_ROW_KEY = '__parent__'
const selectedEntryKey = ref<string | null>(null)
const showHiddenFiles = ref(false)
const searchQuery = ref('')
@@ -61,10 +65,44 @@ watch([searchQuery, showHiddenFiles, files], () => {
}, 300)
}, { immediate: true })
watch(currentPath, (path) => {
pathDraft.value = path
}, { immediate: true })
const showParentEntry = computed(() => currentPath.value !== '.' && pathParts.value.length > 1)
const entryKeys = computed(() => {
const keys: string[] = []
if (showParentEntry.value) {
keys.push(UP_ROW_KEY)
}
filteredFiles.value.forEach((file) => {
keys.push(`file:${file.name}`)
})
return keys
})
watch(entryKeys, (keys) => {
if (keys.length === 0) {
selectedEntryKey.value = null
return
}
if (!selectedEntryKey.value || !keys.includes(selectedEntryKey.value)) {
selectedEntryKey.value = keys[0] ?? null
}
}, { immediate: true })
onMounted(() => {
window.addEventListener('keydown', handleGlobalShortcuts)
})
onBeforeUnmount(() => {
invalidateUploadContext()
clearTimeout(searchDebounceTimer)
stopTransferProgress()
window.removeEventListener('keydown', handleGlobalShortcuts)
})
const showUploadProgress = ref(false)
@@ -193,7 +231,7 @@ function resetVolatileSftpState() {
filteredFiles.value = []
loading.value = false
error.value = ''
selectedFile.value = null
selectedEntryKey.value = null
searchQuery.value = ''
showUploadProgress.value = false
@@ -357,6 +395,17 @@ function navigateToIndex(i: number) {
loadPath()
}
function navigateToTypedPath() {
if (loading.value) return
const raw = pathDraft.value.trim()
if (!raw) return
currentPath.value = raw
pathParts.value = raw === '/' ? [''] : raw.split('/').filter(Boolean)
loadPath()
}
function goUp() {
if (loading.value) return
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
@@ -375,13 +424,116 @@ function goUp() {
}
function handleFileClick(file: SftpFileInfo) {
selectedEntryKey.value = `file:${file.name}`
if (file.directory) {
navigateToDir(file.name)
} else {
selectedFile.value = file.name
selectedEntryKey.value = `file:${file.name}`
}
}
function focusSearch() {
searchInputRef.value?.focus()
searchInputRef.value?.select()
}
function focusPathInput() {
pathInputRef.value?.focus()
pathInputRef.value?.select()
}
function handleGlobalShortcuts(event: KeyboardEvent) {
if (showTransferModal.value) return
const key = event.key.toLowerCase()
if ((event.ctrlKey || event.metaKey) && key === 'f') {
event.preventDefault()
focusSearch()
return
}
if ((event.ctrlKey || event.metaKey) && key === 'l') {
event.preventDefault()
focusPathInput()
return
}
if (event.key === 'F5') {
event.preventDefault()
loadPath()
return
}
if (event.altKey && event.key === 'ArrowUp') {
event.preventDefault()
goUp()
return
}
if (isTypingTarget(event.target)) return
if (event.key === 'ArrowDown') {
event.preventDefault()
moveSelection(1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveSelection(-1)
return
}
if (event.key === 'Enter') {
event.preventDefault()
activateSelection()
return
}
if (event.key === 'Backspace') {
event.preventDefault()
goUp()
}
}
function isTypingTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
const tag = target.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
}
function moveSelection(delta: number) {
const keys = entryKeys.value
if (keys.length === 0) return
const currentIndex = selectedEntryKey.value ? keys.indexOf(selectedEntryKey.value) : -1
const startIndex = currentIndex === -1 ? 0 : currentIndex
const nextIndex = (startIndex + delta + keys.length) % keys.length
selectedEntryKey.value = keys[nextIndex] ?? null
}
function activateSelection() {
const selectedKey = selectedEntryKey.value
if (!selectedKey) return
if (selectedKey === UP_ROW_KEY) {
goUp()
return
}
const fileName = selectedKey.replace(/^file:/, '')
const target = filteredFiles.value.find((file) => file.name === fileName)
if (!target) return
if (target.directory) {
navigateToDir(target.name)
return
}
handleDownload(target)
}
function handleDownload(file: SftpFileInfo) {
if (file.directory) return
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
@@ -575,24 +727,55 @@ async function submitTransfer() {
</script>
<template>
<div class="h-full flex flex-col">
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
<button
@click="router.push('/connections')"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="返回"
>
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
</button>
<h2 class="text-lg font-semibold text-slate-100">
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
</h2>
</div>
<div class="h-full flex flex-col p-3 md:p-4 gap-3">
<section class="rounded-lg border border-slate-700 bg-slate-900/75 backdrop-blur-sm overflow-hidden">
<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">
<button
@click="router.push('/connections')"
class="p-1.5 rounded text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="返回"
>
<ArrowLeft class="w-4 h-4" aria-hidden="true" />
</button>
<h2 class="text-sm md:text-base font-semibold text-slate-100 truncate">
{{ conn?.name || 'SFTP' }}
</h2>
<span class="hidden md:inline text-xs text-slate-500 truncate">{{ conn?.username }}@{{ conn?.host }}</span>
</div>
<div class="flex items-center gap-2">
<button
@click="triggerUpload"
:disabled="uploading"
class="min-h-[34px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors duration-200 cursor-pointer disabled:opacity-50 text-xs flex items-center gap-1.5"
aria-label="上传"
>
<Upload class="w-3.5 h-3.5" aria-hidden="true" />
上传
</button>
<button
@click="handleMkdir"
class="min-h-[34px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors duration-200 cursor-pointer text-xs flex items-center gap-1.5"
aria-label="新建文件夹"
>
<FolderPlus class="w-3.5 h-3.5" aria-hidden="true" />
新建目录
</button>
<button
@click="loadPath()"
:disabled="loading"
class="min-h-[34px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors duration-200 cursor-pointer disabled:opacity-50 text-xs flex items-center gap-1.5"
aria-label="刷新"
>
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" aria-hidden="true" />
刷新
</button>
</div>
</div>
<div class="flex-1 overflow-auto p-4">
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 w-full sm:flex-1">
<div class="p-3 border-b border-slate-700/80 bg-slate-900/80">
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 w-full lg:flex-1 overflow-x-auto">
<button
@click="navigateToIndex(-1)"
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
@@ -603,52 +786,38 @@ async function submitTransfer() {
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" aria-hidden="true" />
<button
@click="navigateToIndex(i)"
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[120px]"
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[160px]"
>
{{ part || '/' }}
</button>
</template>
</nav>
<div class="w-full sm:w-auto flex items-center gap-2 justify-end">
<div class="flex-1 sm:flex-none">
<input
v-model="searchQuery"
type="text"
class="w-full sm:w-56 rounded-lg border border-slate-600 bg-slate-900/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
placeholder="搜索文件..."
aria-label="搜索文件"
/>
</div>
<div class="w-full lg:w-auto flex items-center gap-2">
<input
ref="pathInputRef"
v-model="pathDraft"
type="text"
class="w-full lg:w-72 rounded-md border border-slate-600 bg-slate-950/70 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
placeholder="输入路径后回车跳转"
aria-label="路径输入"
@keydown.enter.prevent="navigateToTypedPath"
/>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="w-full lg:w-64 rounded-md border border-slate-600 bg-slate-950/70 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
placeholder="过滤当前目录文件..."
aria-label="搜索文件"
/>
<button
@click="showHiddenFiles = !showHiddenFiles"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
class="min-h-[40px] min-w-[40px] rounded-md text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer border border-slate-600"
:aria-label="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
:title="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
>
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="triggerUpload"
:disabled="uploading"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
aria-label="上传"
>
<Upload class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="handleMkdir"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
aria-label="新建文件夹"
>
<FolderPlus class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="loadPath()"
:disabled="loading"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
aria-label="刷新"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4 mx-auto" aria-hidden="true" />
</button>
</div>
<input
@@ -659,8 +828,12 @@ async function submitTransfer() {
@change="handleFileSelect"
/>
</div>
<p class="mt-2 text-[11px] text-slate-500">
快捷键Ctrl/Cmd+F 搜索Ctrl/Cmd+L 路径输入/ 选择行Enter 打开F5 刷新Alt+ Backspace 返回上级目录
</p>
</div>
<div v-if="showUploadProgress" class="bg-slate-800/50 border-b border-slate-700 p-4 space-y-3">
<div v-if="showUploadProgress" class="bg-slate-800/55 border-b border-slate-700 p-4 space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-slate-300">上传进度: {{ totalProgress }}%</span>
<span class="text-sm text-slate-400">{{ currentUploadingFile || '准备上传...' }}</span>
@@ -695,39 +868,56 @@ async function submitTransfer() {
</div>
</div>
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
<div v-if="loading" class="p-8 text-center text-slate-400">
<div v-if="loading" class="p-8 text-center text-slate-400">
加载中...
</div>
<div v-else>
<div class="grid grid-cols-[2.6fr_1fr_1.3fr_132px] gap-2 px-4 py-2 text-xs uppercase tracking-wider text-slate-500 border-b border-slate-700 bg-slate-900/85">
<span>名称</span>
<span>大小</span>
<span>修改时间</span>
<span class="text-right pr-1">操作</span>
</div>
<div v-else class="divide-y divide-slate-700">
<div class="divide-y divide-slate-800">
<button
v-if="currentPath !== '.' && pathParts.length > 1"
v-if="showParentEntry"
@click="goUp"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left"
class="w-full grid grid-cols-[2.6fr_1fr_1.3fr_132px] gap-2 items-center px-4 py-3 hover:bg-slate-800/60 transition-colors duration-200 cursor-pointer text-left"
:class="selectedEntryKey === UP_ROW_KEY ? 'bg-cyan-950/25 ring-1 ring-inset ring-cyan-700/50' : ''"
@mouseenter="selectedEntryKey = UP_ROW_KEY"
>
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
<span class="text-slate-400">..</span>
<span class="flex items-center gap-3 min-w-0">
<FolderOpen class="w-4 h-4 text-slate-500 flex-shrink-0" aria-hidden="true" />
<span class="text-slate-300 truncate">..</span>
</span>
<span class="text-slate-600">-</span>
<span class="text-slate-600">-</span>
<span></span>
</button>
<button
v-for="file in filteredFiles"
:key="file.name"
@click="handleFileClick(file)"
@dblclick="!file.directory && handleDownload(file)"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
class="w-full grid grid-cols-[2.6fr_1fr_1.3fr_132px] gap-2 items-center px-4 py-3 hover:bg-slate-800/60 transition-colors duration-200 cursor-pointer text-left group"
:class="selectedEntryKey === `file:${file.name}` ? 'bg-cyan-950/25 ring-1 ring-inset ring-cyan-700/50' : ''"
@mouseenter="selectedEntryKey = `file:${file.name}`"
>
<component
:is="file.directory ? FolderOpen : File"
class="w-5 h-5 flex-shrink-0 text-slate-400"
aria-hidden="true"
/>
<span class="flex-1 truncate text-slate-200">{{ file.name }}</span>
<span v-if="!file.directory" class="text-sm text-slate-500 flex-shrink-0">
{{ formatSize(file.size) }}
<span class="min-w-0 flex items-center gap-3">
<component
:is="file.directory ? FolderOpen : File"
class="w-4 h-4 flex-shrink-0 text-slate-400"
aria-hidden="true"
/>
<span class="truncate text-sm text-slate-200">{{ file.name }}</span>
</span>
<span class="text-sm text-slate-500 flex-shrink-0">{{ formatDate(file.mtime) }}</span>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<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 group-focus-within:opacity-100 transition-opacity">
<button
v-if="!file.directory"
@click.stop="handleDownload(file)"
@@ -758,7 +948,7 @@ async function submitTransfer() {
</div>
</div>
</div>
</div>
</section>
<Teleport to="body">
<div

View File

@@ -1,37 +1,148 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTerminalTabsStore } from '../stores/terminalTabs'
import { useConnectionsStore } from '../stores/connections'
import TerminalWidget from '../components/TerminalWidget.vue'
import { TerminalSquare, X, Plus, Monitor } from 'lucide-vue-next'
const props = defineProps<{
visible?: boolean
}>()
const router = useRouter()
const tabsStore = useTerminalTabsStore()
const connectionsStore = useConnectionsStore()
const tabs = computed(() => tabsStore.tabs)
const activeTab = computed(() => tabsStore.activeTab)
function activateTab(tabId: string) {
tabsStore.activate(tabId)
}
function closeTab(tabId: string, event: Event) {
event.stopPropagation()
tabsStore.close(tabId)
if (tabsStore.tabs.length === 0) {
router.push('/connections')
}
}
function openConnections() {
router.push('/connections')
}
onMounted(() => {
// 确保连接列表已加载
if (connectionsStore.connections.length === 0) {
connectionsStore.fetchConnections()
}
window.addEventListener('keydown', handleTabShortcuts)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleTabShortcuts)
})
function activateByOffset(offset: number) {
if (tabs.value.length <= 1 || !activeTab.value) return
const currentIndex = tabs.value.findIndex((tab) => tab.id === activeTab.value?.id)
if (currentIndex === -1) return
const nextIndex = (currentIndex + offset + tabs.value.length) % tabs.value.length
const nextTab = tabs.value[nextIndex]
if (!nextTab) return
tabsStore.activate(nextTab.id)
}
function handleTabShortcuts(event: KeyboardEvent) {
if (!(event.ctrlKey || event.metaKey)) return
if (tabs.value.length === 0) return
const key = event.key.toLowerCase()
if (key === 'w' && activeTab.value) {
event.preventDefault()
tabsStore.close(activeTab.value.id)
if (tabsStore.tabs.length === 0) {
router.push('/connections')
}
return
}
if (event.key === 'Tab') {
event.preventDefault()
activateByOffset(event.shiftKey ? -1 : 1)
}
}
</script>
<template>
<div class="h-full flex flex-col">
<!-- 终端内容区 -->
<div class="flex-1 min-h-0 p-4">
<div v-if="tabs.length === 0" class="flex items-center justify-center h-full text-slate-400">
<div class="text-center">
<p class="text-lg mb-2">暂无打开的终端</p>
<p class="text-sm text-slate-500">从左侧连接列表点击"终端"按钮打开</p>
<div class="h-full flex flex-col p-3 md:p-4 gap-3">
<section class="rounded-lg border border-slate-700 bg-slate-900/75 backdrop-blur-sm overflow-hidden">
<div class="h-10 px-3 flex items-center justify-between border-b border-slate-700">
<div class="flex items-center gap-2 text-xs uppercase tracking-wider text-slate-400">
<TerminalSquare class="w-4 h-4 text-cyan-400" aria-hidden="true" />
<span>Terminal Workspace</span>
<span class="px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-[11px]">{{ tabs.length }}</span>
<span class="hidden lg:inline text-[11px] text-slate-500 normal-case">Ctrl/Cmd+Tab 切换Ctrl/Cmd+W 关闭</span>
</div>
<button
type="button"
@click="openConnections"
class="min-h-[32px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors duration-200 cursor-pointer text-xs flex items-center gap-1.5"
>
<Plus class="w-3.5 h-3.5" aria-hidden="true" />
新建会话
</button>
</div>
<div v-if="tabs.length > 0" class="px-2 py-1.5 border-b border-slate-800 bg-slate-900/80 flex items-center gap-1 overflow-x-auto">
<div
v-for="tab in tabs"
:key="tab.id"
class="group shrink-0 max-w-[260px] min-h-[34px] flex items-center gap-1.5 px-1.5 rounded-md border text-xs transition-colors duration-200"
:class="tab.active
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-200'
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600 hover:text-slate-100'"
>
<button
type="button"
@click="activateTab(tab.id)"
class="min-h-[30px] min-w-0 flex-1 flex items-center gap-2 px-1 cursor-pointer text-left"
:aria-label="`激活终端标签 ${tab.title}`"
>
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-cyan-400' : 'bg-slate-500'" />
<span class="truncate">{{ tab.title }}</span>
</button>
<button
type="button"
@click="(e) => closeTab(tab.id, e)"
class="p-0.5 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200 cursor-pointer"
aria-label="关闭终端标签"
>
<X class="w-3 h-3" aria-hidden="true" />
</button>
</div>
</div>
<div v-else class="h-full">
</section>
<section class="flex-1 min-h-0 rounded-lg border border-slate-700 bg-slate-950 overflow-hidden">
<div v-if="tabs.length === 0" class="h-full flex items-center justify-center px-4">
<div class="text-center">
<Monitor class="w-12 h-12 text-slate-600 mx-auto mb-3" aria-hidden="true" />
<p class="text-base text-slate-300 mb-1">暂无打开的终端</p>
<p class="text-sm text-slate-500 mb-4"> Connections 页面启动会话支持并行多标签</p>
<button
@click="openConnections"
class="min-h-[44px] px-4 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white text-sm transition-colors duration-200 cursor-pointer"
>
前往连接列表
</button>
</div>
</div>
<div v-else class="h-full min-h-0">
<div
v-for="tab in tabs"
:key="tab.id"
@@ -44,6 +155,12 @@ onMounted(() => {
/>
</div>
</div>
</div>
</section>
<section v-if="activeTab" class="hidden lg:block rounded-lg border border-slate-700 bg-slate-900/65 px-3 py-2">
<p class="text-xs text-slate-400">
当前会话<span class="text-cyan-300">{{ activeTab.title }}</span>
</p>
</section>
</div>
</template>