feat: 支持应用内多 SSH 终端标签页
- 新增 terminalTabs store 管理标签页状态 - 新增 TerminalWorkspaceView 终端工作区视图 - 修改 ConnectionsView 终端按钮改用标签页模式 - 修改 TerminalView 作为兼容入口自动跳转到工作区 - 同一连接默认只保留一个标签页 - 切换标签时保持各自 SSH 会话不断开
This commit is contained in:
@@ -24,16 +24,21 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Transfers',
|
||||
component: () => import('../views/TransfersView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'connections',
|
||||
name: 'Connections',
|
||||
component: () => import('../views/ConnectionsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'connections',
|
||||
name: 'Connections',
|
||||
component: () => import('../views/ConnectionsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'terminal',
|
||||
name: 'TerminalWorkspace',
|
||||
component: () => import('../views/TerminalWorkspaceView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'terminal/:id',
|
||||
name: 'Terminal',
|
||||
component: () => import('../views/TerminalView.vue'),
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'sftp/:id',
|
||||
name: 'Sftp',
|
||||
|
||||
74
frontend/src/stores/terminalTabs.ts
Normal file
74
frontend/src/stores/terminalTabs.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Connection } from '../api/connections'
|
||||
|
||||
export interface TerminalTab {
|
||||
id: string
|
||||
connectionId: number
|
||||
title: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export const useTerminalTabsStore = defineStore('terminalTabs', () => {
|
||||
const tabs = ref<TerminalTab[]>([])
|
||||
const activeTabId = ref<string | null>(null)
|
||||
|
||||
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
|
||||
|
||||
function generateTabId() {
|
||||
return `tab-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function openOrFocus(connection: Connection) {
|
||||
// 检查是否已存在该连接的标签页
|
||||
const existing = tabs.value.find(t => t.connectionId === connection.id)
|
||||
if (existing) {
|
||||
activate(existing.id)
|
||||
return existing.id
|
||||
}
|
||||
|
||||
// 创建新标签页
|
||||
const newTab: TerminalTab = {
|
||||
id: generateTabId(),
|
||||
connectionId: connection.id,
|
||||
title: connection.name,
|
||||
active: true,
|
||||
}
|
||||
|
||||
tabs.value.push(newTab)
|
||||
activate(newTab.id)
|
||||
return newTab.id
|
||||
}
|
||||
|
||||
function activate(tabId: string) {
|
||||
tabs.value.forEach(t => {
|
||||
t.active = t.id === tabId
|
||||
})
|
||||
activeTabId.value = tabId
|
||||
}
|
||||
|
||||
function close(tabId: string) {
|
||||
const index = tabs.value.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return
|
||||
|
||||
const wasActive = tabs.value[index]!.active
|
||||
tabs.value.splice(index, 1)
|
||||
|
||||
// 如果关闭的是活动标签,激活相邻标签
|
||||
if (wasActive && tabs.value.length > 0) {
|
||||
const newIndex = Math.min(index, tabs.value.length - 1)
|
||||
activate(tabs.value[newIndex]!.id)
|
||||
} else if (tabs.value.length === 0) {
|
||||
activeTabId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
openOrFocus,
|
||||
activate,
|
||||
close,
|
||||
}
|
||||
})
|
||||
@@ -2,21 +2,23 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||
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,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Key,
|
||||
Lock,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useConnectionsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingConn = ref<Connection | null>(null)
|
||||
@@ -53,12 +55,13 @@ async function handleDelete(conn: Connection) {
|
||||
}
|
||||
|
||||
function openTerminal(conn: Connection) {
|
||||
router.push(`/terminal/${conn.id}`)
|
||||
}
|
||||
|
||||
function openSftp(conn: Connection) {
|
||||
router.push(`/sftp/${conn.id}`)
|
||||
tabsStore.openOrFocus(conn)
|
||||
router.push('/terminal')
|
||||
}
|
||||
|
||||
function openSftp(conn: Connection) {
|
||||
router.push(`/sftp/${conn.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import TerminalWidget from '../components/TerminalWidget.vue'
|
||||
import { ArrowLeft } from 'lucide-vue-next'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useConnectionsStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
|
||||
const connectionId = computed(() => Number(route.params.id))
|
||||
const conn = ref(store.getConnection(connectionId.value))
|
||||
|
||||
onMounted(() => {
|
||||
conn.value = store.getConnection(connectionId.value)
|
||||
if (!conn.value) {
|
||||
store.fetchConnections().then(() => {
|
||||
conn.value = store.getConnection(connectionId.value)
|
||||
})
|
||||
onMounted(async () => {
|
||||
// 确保连接列表已加载
|
||||
if (connectionsStore.connections.length === 0) {
|
||||
await connectionsStore.fetchConnections()
|
||||
}
|
||||
|
||||
const conn = connectionsStore.getConnection(connectionId.value)
|
||||
if (conn) {
|
||||
// 打开或聚焦该连接的标签页
|
||||
tabsStore.openOrFocus(conn)
|
||||
// 跳转到工作区
|
||||
router.replace('/terminal')
|
||||
} else {
|
||||
// 连接不存在,返回连接列表
|
||||
router.replace('/connections')
|
||||
}
|
||||
})
|
||||
</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="flex-1 min-h-0 p-4">
|
||||
<TerminalWidget v-if="conn" :connection-id="conn.id" />
|
||||
<div v-else class="flex items-center justify-center h-64 text-slate-400">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-center text-slate-400">
|
||||
正在打开终端...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
98
frontend/src/views/TerminalWorkspaceView.vue
Normal file
98
frontend/src/views/TerminalWorkspaceView.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import TerminalWidget from '../components/TerminalWidget.vue'
|
||||
import { ArrowLeft, X } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
|
||||
const tabs = computed(() => tabsStore.tabs)
|
||||
|
||||
onMounted(() => {
|
||||
// 确保连接列表已加载
|
||||
if (connectionsStore.connections.length === 0) {
|
||||
connectionsStore.fetchConnections()
|
||||
}
|
||||
})
|
||||
|
||||
function handleTabClick(tabId: string) {
|
||||
tabsStore.activate(tabId)
|
||||
}
|
||||
|
||||
function handleTabClose(tabId: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
tabsStore.close(tabId)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/connections')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- 顶部导航栏 -->
|
||||
<div class="flex items-center gap-4 px-4 py-3 border-b border-slate-700 bg-slate-800/50">
|
||||
<button
|
||||
@click="goBack"
|
||||
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">终端</h2>
|
||||
</div>
|
||||
|
||||
<!-- 标签栏 -->
|
||||
<div
|
||||
v-if="tabs.length > 0"
|
||||
class="flex items-center gap-2 px-4 py-2 border-b border-slate-700 bg-slate-900/50 overflow-x-auto"
|
||||
>
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="handleTabClick(tab.id)"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors duration-200 whitespace-nowrap"
|
||||
:class="tab.active ? 'bg-slate-700 text-slate-100' : 'bg-slate-800 text-slate-400 hover:bg-slate-700/50 hover:text-slate-200'"
|
||||
>
|
||||
<span class="text-sm font-medium">{{ tab.title }}</span>
|
||||
<button
|
||||
@click="(e) => handleTabClose(tab.id, e)"
|
||||
class="p-1 rounded hover:bg-slate-600 transition-colors duration-200"
|
||||
aria-label="关闭标签"
|
||||
>
|
||||
<X class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 终端内容区 -->
|
||||
<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="mb-2">暂无打开的终端</p>
|
||||
<button
|
||||
@click="goBack"
|
||||
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>
|
||||
<div v-else class="h-full">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
v-show="tab.active"
|
||||
class="h-full"
|
||||
>
|
||||
<TerminalWidget :connection-id="tab.connectionId" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user