feat(frontend): 重构工作区终端与导航交互
将 SSH 顶部标签改为服务器级切换入口,并把同服务器下的 多终端切换、新增与关闭下沉到终端面板内部,修正服务器与 终端的视觉层级 同时将 Workbench 导航改为左侧图标栏,并为终端标签右键菜单 补充“关闭全部”动作,完善相关多语言文案与工作区事件处理
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1590,6 +1590,19 @@
|
||||
"openConnectionPickerTooltip": "別のサーバーを選択",
|
||||
"terminalBadge": "端末 {index}"
|
||||
},
|
||||
"tabs": {
|
||||
"contextMenu": {
|
||||
"close": "タブを閉じる",
|
||||
"closeAll": "すべて閉じる",
|
||||
"closeOthers": "他のタブを閉じる",
|
||||
"closeRight": "右側のタブを閉じる",
|
||||
"closeLeft": "左側のタブを閉じる",
|
||||
"suspendSession": "セッションを中断",
|
||||
"unmarkForSuspend": "中断マークを解除"
|
||||
},
|
||||
"closeTabTooltip": "タブを閉じる",
|
||||
"newTabTooltip": "新しい接続タブ"
|
||||
},
|
||||
"workspace": {
|
||||
"terminal": {
|
||||
"reconnectingMsg": "再接続を試行中..."
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user