feat(frontend): 重构工作区终端与导航交互

将 SSH 顶部标签改为服务器级切换入口,并把同服务器下的
多终端切换、新增与关闭下沉到终端面板内部,修正服务器与
终端的视觉层级

同时将 Workbench 导航改为左侧图标栏,并为终端标签右键菜单
补充“关闭全部”动作,完善相关多语言文案与工作区事件处理
This commit is contained in:
yinjianm
2026-03-29 23:01:49 +08:00
parent 26acdba7e8
commit d3e8d598b8
20 changed files with 762 additions and 164 deletions
+1 -1
View File
@@ -286,8 +286,8 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
<!-- 项目 Logo -->
<img src="./assets/logo.png" alt="Project Logo" class="h-10 w-auto"> <!-- 移除右侧外边距使其更靠左 -->
<RouterLink to="/" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.dashboard') }}</RouterLink> <!-- 恢复仪表盘链接, 始终可见 -->
<RouterLink to="/workspace" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.terminal') }}</RouterLink> <!-- 保持可见 -->
<RouterLink to="/connections" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.connections') }}</RouterLink> <!-- 连接管理链接 -->
<RouterLink to="/workspace" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.terminal') }}</RouterLink> <!-- 保持可见 -->
<RouterLink to="/proxies" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.proxies') }}</RouterLink> <!-- 移动端隐藏 -->
<RouterLink to="/notifications" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.notifications') }}</RouterLink> <!-- 移动端隐藏 -->
<RouterLink to="/audit-logs" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.auditLogs') }}</RouterLink> <!-- 移动端隐藏 -->
@@ -12,6 +12,7 @@ import { useSettingsStore } from '../stores/settings.store';
import { useAppearanceStore } from '../stores/appearance.store'; // +++ Import appearance store +++
import { useSidebarResize } from '../composables/useSidebarResize';
import { storeToRefs } from 'pinia';
import type { SessionTabInfoWithStatus } from '../stores/session/types';
// --- Props ---
@@ -66,7 +67,7 @@ const {
terminalCustomHTML,
} = storeToRefs(appearanceStore);
const { activeSession } = storeToRefs(sessionStore);
const { activeSession, sessionTabsWithStatus } = storeToRefs(sessionStore);
const { workspaceSidebarPersistentBoolean, getSidebarPaneWidth } = storeToRefs(settingsStore);
const { sidebarPanes } = storeToRefs(layoutStore);
const { orderedTabs: editorTabsFromStore, activeTabId: activeEditorTabIdFromStore } = storeToRefs(fileEditorStore); // <-- Get editor state
@@ -125,6 +126,47 @@ const hasSshSessions = computed(() => {
return false;
});
const activeTerminalConnectionId = computed(() => {
if (!props.activeSessionId) {
return null;
}
const sessionState = sessionStore.sessions.get(props.activeSessionId);
if (!sessionState?.terminalManager) {
return null;
}
return sessionState.connectionId;
});
const activeTerminalSessions = computed<SessionTabInfoWithStatus[]>(() => {
if (!activeTerminalConnectionId.value) {
return [];
}
return sessionTabsWithStatus.value.filter((session) => {
const sessionState = sessionStore.sessions.get(session.sessionId);
return session.connectionId === activeTerminalConnectionId.value && Boolean(sessionState?.terminalManager);
});
});
const activeTerminalConnectionName = computed(() => activeTerminalSessions.value[0]?.connectionName ?? '');
const openTerminalSibling = () => {
if (!activeTerminalConnectionId.value) {
return;
}
sessionStore.handleOpenNewSession(activeTerminalConnectionId.value);
};
const closeTerminalSession = (sessionId: string) => {
sessionStore.closeSession(sessionId);
};
const getTerminalSessionTitle = (session: SessionTabInfoWithStatus) =>
`${session.connectionName} / ${t('terminalTabBar.terminalBadge', { index: session.terminalIndex })}`;
// 面板标签 (Similar to LayoutConfigurator)
const paneLabels = computed(() => ({
connections: t('layout.pane.connections', '连接列表'),
@@ -573,9 +615,67 @@ onBeforeUnmount(() => {
<!-- Terminal Pane: Render ALL SSH sessions, show only the active one -->
<template v-if="layoutNode.component === 'terminal'">
<div
class="terminal-pane-container relative flex-grow overflow-hidden"
class="terminal-pane-container flex h-full flex-col overflow-hidden"
:class="{ 'has-global-terminal-background': isTerminalBackgroundEnabled, 'bg-background': !isTerminalBackgroundEnabled }"
>
<div
v-if="activeTerminalSessions.length > 0"
class="flex items-center gap-2 border-b border-border bg-header/95 px-2 py-1.5"
>
<div class="flex min-w-0 items-center gap-2 rounded-md border border-border/70 bg-background/70 px-2.5 py-1 text-xs text-text-secondary">
<i class="fas fa-server text-[10px] text-primary/80"></i>
<span class="truncate font-semibold text-foreground">{{ activeTerminalConnectionName }}</span>
<span class="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-foreground/80">
{{ t('terminalTabBar.terminalCount', { count: activeTerminalSessions.length }) }}
</span>
</div>
<div class="flex min-w-0 flex-1 items-center overflow-x-auto">
<div
v-for="session in activeTerminalSessions"
:key="session.sessionId"
role="button"
tabindex="0"
:class="[
'group flex h-8 items-center rounded-md border px-2.5 text-[11px] font-medium transition-colors duration-150',
session.sessionId === activeSessionId
? 'border-primary/60 bg-primary/10 text-foreground'
: 'border-transparent bg-background/70 text-text-secondary hover:border-border hover:bg-border hover:text-foreground',
]"
:title="getTerminalSessionTitle(session)"
@click="sessionStore.activateSession(session.sessionId)"
@keydown.enter.prevent="sessionStore.activateSession(session.sessionId)"
@keydown.space.prevent="sessionStore.activateSession(session.sessionId)"
>
<span :class="['mr-2 h-2 w-2 rounded-full',
session.isMarkedForSuspend ? 'bg-blue-500' :
session.status === 'connected' ? 'bg-green-500' :
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
<span class="whitespace-nowrap">
{{ t('terminalTabBar.terminalBadge', { index: session.terminalIndex }) }}
</span>
<button
type="button"
class="ml-2 rounded-full p-0.5 text-text-secondary opacity-0 transition-opacity duration-150 hover:bg-header hover:text-foreground group-hover:opacity-100"
@click.stop="closeTerminalSession(session.sessionId)"
:title="$t('tabs.closeTabTooltip')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<button
type="button"
class="flex h-8 items-center justify-center rounded-md border border-border/70 bg-background/70 px-2.5 text-text-secondary transition-colors duration-150 hover:bg-border hover:text-foreground"
:title="t('terminalTabBar.newTerminalTooltip')"
@click="openTerminalSibling"
>
<i class="fas fa-plus text-[11px]"></i>
</button>
</div>
<div class="relative flex-1 overflow-hidden">
<!-- Shared Background Layers -->
<div
v-if="isTerminalBackgroundEnabled"
@@ -632,6 +732,7 @@ onBeforeUnmount(() => {
<div class="text-xs text-text-secondary mt-2">{{ activeSessionId ? t('layout.noSshSessionActive.message', '请激活一个 SSH 会话以使用此终端面板。') : t('layout.noActiveSession.message') }}</div>
</div>
</div>
</div>
</div>
</template>
<!-- FileManager -->
@@ -72,6 +72,64 @@ const activeConnectionId = computed(() => {
return sessionStore.sessions.get(props.activeSessionId)?.connectionId ?? null;
});
const getConnectionInfoById = (connectionId: string) =>
connectionsStore.connections.find((connection) => connection.id === Number(connectionId)) ?? null;
const isSshConnection = (connectionId: string) => getConnectionInfoById(connectionId)?.type === 'SSH';
const getConnectionSessions = (connectionId: string) =>
draggableSessions.value.filter((session) => session.connectionId === connectionId);
const getRepresentativeSessionId = (connectionId: string, fallbackSessionId: string) => {
if (activeConnectionId.value === connectionId && props.activeSessionId) {
return props.activeSessionId;
}
return getConnectionSessions(connectionId)[0]?.sessionId ?? fallbackSessionId;
};
const getConnectionSessionCount = (connectionId: string) => getConnectionSessions(connectionId).length;
const shouldRenderTopLevelItem = (session: SessionTabInfoWithStatus, index: number) => {
if (!isSshConnection(session.connectionId)) {
return true;
}
return isGroupStart(index);
};
const hasCollapsedSshGroups = computed(() =>
draggableSessions.value.some((session, index) => isSshConnection(session.connectionId) && !isGroupStart(index))
);
const activateTopLevelItem = (session: SessionTabInfoWithStatus) => {
if (isSshConnection(session.connectionId)) {
activateSession(getRepresentativeSessionId(session.connectionId, session.sessionId));
return;
}
activateSession(session.sessionId);
};
const showTopLevelContextMenu = (event: MouseEvent, session: SessionTabInfoWithStatus) => {
const targetSessionId = isSshConnection(session.connectionId)
? getRepresentativeSessionId(session.connectionId, session.sessionId)
: session.sessionId;
showContextMenu(event, targetSessionId);
};
const getTopLevelItemTitle = (session: SessionTabInfoWithStatus) => {
if (!isSshConnection(session.connectionId)) {
return `${session.connectionName} / ${t('terminalTabBar.terminalBadge', { index: session.terminalIndex })}`;
}
return t('terminalTabBar.serverEntryTitle', {
name: session.connectionName,
count: getConnectionSessionCount(session.connectionId),
});
};
const openConnectionPicker = () => {
showConnectionListPopup.value = true;
};
@@ -85,27 +143,6 @@ const isGroupStart = (index: number) => {
return Boolean(currentSession && (!previousSession || previousSession.connectionId !== currentSession.connectionId));
};
const isGroupEnd = (index: number) => {
const currentSession = getSessionAtIndex(index);
const nextSession = getSessionAtIndex(index + 1);
return Boolean(currentSession && (!nextSession || nextSession.connectionId !== currentSession.connectionId));
};
const getConnectionInfoById = (connectionId: string) =>
connectionsStore.connections.find((connection) => connection.id === Number(connectionId)) ?? null;
const canOpenSiblingTerminal = (connectionId: string) => getConnectionInfoById(connectionId)?.type === 'SSH';
const openNewTerminalForConnection = (connectionId: string) => {
const connectionInfo = getConnectionInfoById(connectionId);
if (!connectionInfo || connectionInfo.type !== 'SSH') {
return;
}
sessionStore.handleOpenNewSession(connectionInfo.id);
};
// + Watch prop changes to update local state
watch(() => props.sessions, (newSessions) => {
// Create a shallow copy to avoid modifying the prop directly
@@ -484,93 +521,75 @@ onBeforeUnmount(() => {
ghost-class="opacity-50"
drag-class="opacity-75"
animation="150"
:disabled="props.isMobile"
:disabled="props.isMobile || hasCollapsedSshGroups"
>
<template #item="{ element: session, index }">
<li
v-if="shouldRenderTopLevelItem(session, index)"
:key="session.sessionId"
:class="['flex h-full flex-shrink-0 items-stretch py-1', isGroupStart(index) ? 'pl-1' : 'pl-0']"
@dragstart="handleDragStart"
>
<div
<button
v-if="isSshConnection(session.connectionId)"
type="button"
:class="[
'flex h-full items-stretch overflow-hidden border transition-all duration-150',
'group flex h-full items-center gap-2 rounded-md border px-3 text-left transition-all duration-150',
session.connectionId === activeConnectionId
? 'border-primary/60 bg-primary/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'border-border/70 bg-header/80 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]',
isGroupStart(index) && isGroupEnd(index)
? 'rounded-md'
: isGroupStart(index)
? 'rounded-l-md rounded-r-none'
: isGroupEnd(index)
? '-ml-px rounded-r-md rounded-l-none'
: '-ml-px rounded-none',
? 'border-primary/60 bg-primary/10 text-foreground shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'border-border/70 bg-header/80 text-text-secondary shadow-[0_0_0_1px_rgba(0,0,0,0.08)] hover:bg-border hover:text-foreground',
]"
:title="getTopLevelItemTitle(session)"
@click="activateTopLevelItem(session)"
@contextmenu.prevent="showTopLevelContextMenu($event, session)"
>
<div
v-if="isGroupStart(index)"
<i class="fas fa-server text-[11px] text-primary/80"></i>
<span class="max-w-[180px] truncate text-xs font-semibold tracking-wide">
{{ session.connectionName }}
</span>
<span
:class="[
'flex max-w-[160px] items-center border-r px-2.5 text-xs font-semibold tracking-wide',
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
session.connectionId === activeConnectionId
? 'border-primary/50 bg-primary/15 text-foreground'
: 'border-border/70 bg-black/15 text-text-secondary',
? 'bg-primary/15 text-foreground/90'
: 'bg-black/20 text-text-secondary group-hover:text-foreground',
]"
:title="session.connectionName"
>
<i class="fas fa-server mr-1.5 text-[10px] text-primary/80"></i>
<span class="truncate">{{ session.connectionName }}</span>
</div>
<div
:class="[
'group flex h-full items-center px-2.5 transition-colors duration-150 relative',
session.sessionId === activeSessionId
? 'bg-background text-foreground shadow-[inset_0_1px_0_rgba(34,197,94,0.15)]'
: session.connectionId === activeConnectionId
? 'bg-primary/5 text-foreground/85 hover:bg-primary/10'
: 'bg-header text-text-secondary hover:bg-border',
!isGroupStart(index) ? 'border-l border-border/60' : '',
]"
@click="activateSession(session.sessionId)"
@contextmenu.prevent="showContextMenu($event, session.sessionId)"
@touchstart="handleTouchStart($event, session.sessionId)"
@touchend="handleTouchEnd($event)"
:title="`${session.connectionName} / ${t('terminalTabBar.terminalBadge', { index: session.terminalIndex })}`"
>
<span :class="['w-2 h-2 rounded-full mr-2 flex-shrink-0',
session.isMarkedForSuspend ? 'bg-blue-500' :
session.status === 'connected' ? 'bg-green-500' :
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
<span class="whitespace-nowrap text-[11px] font-medium">
{{ t('terminalTabBar.terminalBadge', { index: session.terminalIndex }) }}
</span>
<button
type="button"
class="ml-2 rounded-full p-0.5 text-text-secondary opacity-0 transition-opacity duration-150 hover:bg-border hover:text-foreground group-hover:opacity-100"
:class="{ 'text-foreground hover:bg-header': session.sessionId === activeSessionId }"
@click="closeSession($event, session.sessionId)"
:title="$t('tabs.closeTabTooltip')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{ getConnectionSessionCount(session.connectionId) }}
</span>
</button>
<div
v-else
:class="[
'group flex h-full items-center overflow-hidden rounded-md border px-2.5 transition-all duration-150',
session.sessionId === activeSessionId
? 'border-primary/60 bg-primary/10 text-foreground shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'border-border/70 bg-header/80 text-text-secondary shadow-[0_0_0_1px_rgba(0,0,0,0.08)] hover:bg-border hover:text-foreground',
]"
@click="activateTopLevelItem(session)"
@contextmenu.prevent="showTopLevelContextMenu($event, session)"
@touchstart="handleTouchStart($event, session.sessionId)"
@touchend="handleTouchEnd($event)"
:title="getTopLevelItemTitle(session)"
>
<span :class="['w-2 h-2 rounded-full mr-2 flex-shrink-0',
session.isMarkedForSuspend ? 'bg-blue-500' :
session.status === 'connected' ? 'bg-green-500' :
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
<span class="max-w-[180px] truncate text-[11px] font-medium">
{{ session.connectionName }}
</span>
<button
v-if="isGroupEnd(index) && canOpenSiblingTerminal(session.connectionId)"
type="button"
:class="[
'flex h-full items-center border-l px-2.5 transition-colors duration-150',
session.connectionId === activeConnectionId
? 'border-primary/40 bg-primary/10 text-foreground/80 hover:bg-primary/15 hover:text-foreground'
: 'border-border/60 bg-black/10 text-text-secondary hover:bg-border hover:text-foreground',
]"
@click.stop="openNewTerminalForConnection(session.connectionId)"
:title="t('terminalTabBar.newTerminalTooltip')"
class="ml-2 rounded-full p-0.5 text-text-secondary opacity-0 transition-opacity duration-150 hover:bg-border hover:text-foreground group-hover:opacity-100"
:class="{ 'text-foreground hover:bg-header': session.sessionId === activeSessionId }"
@click="closeSession($event, session.sessionId)"
:title="$t('tabs.closeTabTooltip')"
>
<i class="fas fa-plus text-[11px]"></i>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</li>
@@ -97,84 +97,94 @@ watch(
</script>
<template>
<div class="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<div class="border-b border-border bg-header px-3 py-3">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-foreground">
{{ t('workspace.workbench.title', 'Workbench') }}
</h3>
<p class="mt-1 text-xs text-text-secondary">
{{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }}
</p>
</div>
<span class="rounded-full border border-border bg-background px-2 py-1 text-[11px] font-medium text-text-secondary">
{{ t('workspace.workbench.label', '工作台') }}
</span>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
<button
v-for="tab in workbenchTabs"
:key="tab.id"
type="button"
@click="activeWorkbenchTab = tab.id"
:class="[
'inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-colors',
activeWorkbenchTab === tab.id
? 'border-primary bg-primary text-white shadow-sm'
: 'border-border bg-background text-text-secondary hover:border-primary/40 hover:text-foreground'
]"
>
<i :class="tab.icon"></i>
<span>{{ tab.label }}</span>
</button>
</div>
</div>
<div class="flex h-full min-h-0 overflow-hidden bg-background">
<aside class="workbench-rail flex w-14 flex-shrink-0 flex-col items-center gap-2 border-r border-border px-2 py-3">
<button
v-for="tab in workbenchTabs"
:key="tab.id"
type="button"
:title="tab.label"
:aria-label="tab.label"
@click="activeWorkbenchTab = tab.id"
:class="[
'inline-flex h-10 w-10 items-center justify-center rounded-xl border text-sm transition-colors',
activeWorkbenchTab === tab.id
? 'border-primary bg-primary text-white shadow-sm'
: 'border-transparent bg-transparent text-text-secondary hover:border-primary/20 hover:bg-background hover:text-foreground'
]"
>
<i :class="tab.icon"></i>
</button>
</aside>
<div class="relative flex-1 min-h-0 overflow-hidden bg-background">
<div v-show="activeWorkbenchTab === 'quickCommands'" class="absolute inset-0 min-h-0 workbench-quick-commands">
<QuickCommandsView />
</div>
<div v-show="activeWorkbenchTab === 'files'" class="absolute inset-0 min-h-0">
<FileManager
v-if="hasFileManagerContext"
:session-id="fileManagerSessionId"
:instance-id="fileManagerInstanceId"
:db-connection-id="fileManagerConnectionId"
:ws-deps="fileManagerWsDeps"
class="h-full"
/>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-text-secondary"
>
<i class="fas fa-plug text-3xl"></i>
<div class="text-sm font-medium">
{{ t('layout.noActiveSession.title', '没有活动的会话') }}
</div>
<div class="text-xs">
{{ t('workspace.workbench.fileManagerHint', '激活一个 SSH 会话后即可浏览远程文件。') }}
<div class="flex min-w-0 flex-1 flex-col overflow-hidden bg-background">
<div class="border-b border-border bg-header px-4 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<h3 class="text-sm font-semibold text-foreground">
{{ t('workspace.workbench.title', 'Workbench') }}
</h3>
<p class="mt-1 truncate text-xs text-text-secondary">
{{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }}
</p>
</div>
<span class="rounded-full border border-border bg-background px-2 py-1 text-[11px] font-medium text-text-secondary">
{{ t('workspace.workbench.label', '工作台') }}
</span>
</div>
</div>
<div v-show="activeWorkbenchTab === 'history'" class="absolute inset-0 min-h-0">
<CommandHistoryView />
</div>
<div class="relative flex-1 min-h-0 overflow-hidden bg-background">
<div v-show="activeWorkbenchTab === 'quickCommands'" class="absolute inset-0 min-h-0 workbench-quick-commands">
<QuickCommandsView />
</div>
<div v-show="activeWorkbenchTab === 'editor'" class="absolute inset-0 min-h-0">
<FileEditorContainer
:tabs="tabs"
:active-tab-id="activeTabId"
:session-id="sessionId"
/>
<div v-show="activeWorkbenchTab === 'files'" class="absolute inset-0 min-h-0">
<FileManager
v-if="hasFileManagerContext"
:session-id="fileManagerSessionId"
:instance-id="fileManagerInstanceId"
:db-connection-id="fileManagerConnectionId"
:ws-deps="fileManagerWsDeps"
class="h-full"
/>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-text-secondary"
>
<i class="fas fa-plug text-3xl"></i>
<div class="text-sm font-medium">
{{ t('layout.noActiveSession.title', '没有活动的会话') }}
</div>
<div class="text-xs">
{{ t('workspace.workbench.fileManagerHint', '激活一个 SSH 会话后即可浏览远程文件。') }}
</div>
</div>
</div>
<div v-show="activeWorkbenchTab === 'history'" class="absolute inset-0 min-h-0">
<CommandHistoryView />
</div>
<div v-show="activeWorkbenchTab === 'editor'" class="absolute inset-0 min-h-0">
<FileEditorContainer
:tabs="tabs"
:active-tab-id="activeTabId"
:session-id="sessionId"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.workbench-rail {
background:
linear-gradient(180deg, rgba(30, 41, 59, 0.94) 0%, rgba(17, 24, 39, 0.98) 100%);
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
}
.workbench-quick-commands {
background:
linear-gradient(180deg, rgba(15, 17, 22, 0.98) 0%, rgba(12, 14, 18, 1) 100%);
@@ -41,6 +41,7 @@ export type WorkspaceEventPayloads = {
// Session Management Events (主要由 TerminalTabBar 发出)
'session:activate': { sessionId: string };
'session:close': { sessionId: string };
'session:closeAll': void;
'session:closeOthers': { targetSessionId: string };
'session:closeToRight': { targetSessionId: string };
'session:closeToLeft': { targetSessionId: string };
@@ -83,4 +84,4 @@ export function useWorkspaceEventSubscriber() {
*/
export function useWorkspaceEventOff() {
return workspaceEmitter.off;
}
}
+3 -1
View File
@@ -1628,7 +1628,9 @@
"showTransferProgressTooltip": "Show/Hide Transfer Progress",
"newTerminalTooltip": "Open another terminal for the current server",
"openConnectionPickerTooltip": "Choose another server",
"terminalBadge": "Terminal {index}"
"terminalBadge": "Terminal {index}",
"serverEntryTitle": "{name} · {count} terminals",
"terminalCount": "{count} terminals"
},
"tabs": {
"contextMenu": {
+13
View File
@@ -1590,6 +1590,19 @@
"openConnectionPickerTooltip": "別のサーバーを選択",
"terminalBadge": "端末 {index}"
},
"tabs": {
"contextMenu": {
"close": "タブを閉じる",
"closeAll": "すべて閉じる",
"closeOthers": "他のタブを閉じる",
"closeRight": "右側のタブを閉じる",
"closeLeft": "左側のタブを閉じる",
"suspendSession": "セッションを中断",
"unmarkForSuspend": "中断マークを解除"
},
"closeTabTooltip": "タブを閉じる",
"newTabTooltip": "新しい接続タブ"
},
"workspace": {
"terminal": {
"reconnectingMsg": "再接続を試行中..."
+3 -1
View File
@@ -1632,7 +1632,9 @@
"showTransferProgressTooltip": "显示/隐藏传输进度",
"newTerminalTooltip": "为当前服务器新增终端",
"openConnectionPickerTooltip": "选择其他服务器",
"terminalBadge": "终端 {index}"
"terminalBadge": "终端 {index}",
"serverEntryTitle": "{name} · {count} 个终端",
"terminalCount": "{count} 个终端"
},
"tabs": {
"contextMenu": {
@@ -169,6 +169,7 @@ onMounted(() => {
// 来自 TerminalTabBar 的事件
subscribeToWorkspaceEvents('session:activate', (payload) => sessionStore.activateSession(payload.sessionId));
subscribeToWorkspaceEvents('session:close', (payload) => sessionStore.closeSession(payload.sessionId));
subscribeToWorkspaceEvents('session:closeAll', handleCloseAllSessions);
subscribeToWorkspaceEvents('session:closeOthers', (payload) => handleCloseOtherSessions(payload.targetSessionId));
subscribeToWorkspaceEvents('session:closeToRight', (payload) => handleCloseSessionsToRight(payload.targetSessionId));
subscribeToWorkspaceEvents('session:closeToLeft', (payload) => handleCloseSessionsToLeft(payload.targetSessionId));
@@ -214,6 +215,7 @@ onBeforeUnmount(() => {
unsubscribeFromWorkspaceEvents('session:activate', (payload) => sessionStore.activateSession(payload.sessionId));
unsubscribeFromWorkspaceEvents('session:close', (payload) => sessionStore.closeSession(payload.sessionId));
unsubscribeFromWorkspaceEvents('session:closeAll', handleCloseAllSessions);
unsubscribeFromWorkspaceEvents('session:closeOthers', (payload) => handleCloseOtherSessions(payload.targetSessionId));
unsubscribeFromWorkspaceEvents('session:closeToRight', (payload) => handleCloseSessionsToRight(payload.targetSessionId));
unsubscribeFromWorkspaceEvents('session:closeToLeft', (payload) => handleCloseSessionsToLeft(payload.targetSessionId));
@@ -581,6 +583,14 @@ const toggleVirtualKeyboard = () => {
// --- 标签页关闭操作处理 ---
const handleCloseAllSessions = () => {
if (sessionTabsWithStatus.value.length === 0) {
return;
}
sessionStore.cleanupAllSessions();
};
const handleCloseOtherSessions = (targetSessionId: string) => {
const sessionsToClose = sessionTabsWithStatus.value
.filter(tab => tab.sessionId !== targetSessionId)