Files
nexus-terminal/packages/frontend/src/components/TerminalTabBar.vue
T
2025-05-06 09:54:48 +08:00

385 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, PropType, onMounted, onBeforeUnmount, watch } from 'vue';
import draggable from 'vuedraggable';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import WorkspaceConnectionListComponent from './WorkspaceConnectionList.vue';
import TabBarContextMenu from './TabBarContextMenu.vue';
import { useSessionStore } from '../stores/session.store';
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
import { useLayoutStore, type PaneName } from '../stores/layout.store';
import type { SessionTabInfoWithStatus } from '../stores/session.store';
const { t } = useI18n(); // 初始化 i18n
const layoutStore = useLayoutStore(); // 初始化布局 store
const connectionsStore = useConnectionsStore();
const { isHeaderVisible } = storeToRefs(layoutStore); // 从 layout store 获取主导航栏可见状态
const route = useRoute(); // 获取路由实例
// 定义 Props
const props = defineProps({
sessions: {
type: Array as PropType<SessionTabInfoWithStatus[]>,
required: true,
},
activeSessionId: {
type: String as PropType<string | null>,
required: false,
default: null,
},
// +++ 添加 isMobile prop +++
isMobile: {
type: Boolean,
default: false,
},
});
// 定义事件 (使用对象语法修复类型)
const emit = defineEmits<{
(e: 'activate-session', sessionId: string): void;
(e: 'close-session', sessionId: string): void;
(e: 'open-layout-configurator'): void;
(e: 'request-add-connection-from-popup'): void;
(e: 'request-edit-connection-from-popup', connection: any): void; // 保持 any 或使用 ConnectionInfo
// + 新增右键菜单事件
(e: 'close-other-sessions', sessionId: string): void;
(e: 'close-sessions-to-right', sessionId: string): void;
(e: 'close-sessions-to-left', sessionId: string): void;
(e: 'update:sessions', newSessions: SessionTabInfoWithStatus[]): void; // + Add event for reordering
}>();
const activateSession = (sessionId: string) => {
if (sessionId !== props.activeSessionId) {
emit('activate-session', sessionId);
}
};
const closeSession = (event: MouseEvent, sessionId: string) => {
event.stopPropagation(); // 阻止事件冒泡到标签点击事件
emit('close-session', sessionId);
};
// --- 本地状态 ---
const sessionStore = useSessionStore(); // Session store 保持不变
const showConnectionListPopup = ref(false); // 连接列表弹出状态
const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable
// + Watch prop changes to update local state
watch(() => props.sessions, (newSessions) => {
// Create a shallow copy to avoid modifying the prop directly
draggableSessions.value = [...newSessions];
}, { immediate: true, deep: true });
// +++ 右键菜单状态 +++
const contextMenuVisible = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const contextTargetSessionId = ref<string | null>(null); // Keep for logic inside this component if needed elsewhere
const menuTargetId = ref<string | null>(null); // + Ref specifically for passing to the menu prop
const togglePopup = () => {
showConnectionListPopup.value = !showConnectionListPopup.value;
};
// 处理从弹出列表中选择连接的事件
const handlePopupConnect = (connectionId: number) => {
console.log(`[TabBar] Popup connect request for ID: ${connectionId}`);
const connectionInfo = connectionsStore.connections.find(c => c.id === connectionId);
if (!connectionInfo) {
console.error(`[TabBar] handlePopupConnect: 未找到 ID 为 ${connectionId} 的连接信息。`);
showConnectionListPopup.value = false; // 关闭弹出窗口
return;
}
// --- 修改:根据类型决定调用哪个 Action ---
if (connectionInfo.type === 'RDP') {
console.log(`[TabBar] Popup RDP connect request for ID: ${connectionId}. Calling sessionStore.openRdpModal.`);
sessionStore.openRdpModal(connectionInfo);
} else {
console.log(`[TabBar] Popup non-RDP connect request for ID: ${connectionId}. Calling sessionStore.handleConnectRequest.`);
sessionStore.handleConnectRequest(connectionInfo); // 非 RDP 保持原逻辑
}
showConnectionListPopup.value = false; // 关闭弹出窗口
};
// 新增:处理从弹窗内部发出的添加连接请求
const handleRequestAddFromPopup = () => {
console.log('[TabBar] Received request-add-connection from popup component.');
showConnectionListPopup.value = false; // 关闭弹窗
emit('request-add-connection-from-popup'); // 向上发出事件
};
// 新增:处理从弹窗内部发出的编辑连接请求
const handleRequestEditFromPopup = (connection: any) => { // 假设 WorkspaceConnectionList 传递了连接对象
console.log('[TabBar] Received request-edit-connection from popup component for connection:', connection);
showConnectionListPopup.value = false; // 关闭弹窗
// 向上发出事件,并携带连接信息
emit('request-edit-connection-from-popup', connection);
};
// --- 移除 handleRequestRdpFromPopup 方法 ---
// const handleRequestRdpFromPopup = (connection: ConnectionInfo) => { ... };
// +++ 右键菜单方法 +++
const showContextMenu = (event: MouseEvent, sessionId: string) => {
event.preventDefault();
event.stopPropagation();
contextTargetSessionId.value = sessionId; // Still set the original ref if needed elsewhere
menuTargetId.value = sessionId; // + Set the dedicated ref for the prop
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
contextMenuVisible.value = true;
// 添加全局监听器以关闭菜单
document.addEventListener('click', closeContextMenuOnClickOutside, { capture: true, once: true });
};
const closeContextMenu = () => {
contextMenuVisible.value = false;
contextTargetSessionId.value = null; // Clear original ref if needed
// menuTargetId.value = null; // -- REMOVE THIS LINE -- Let the value persist until next show
// 移除监听器(如果它仍然存在)
document.removeEventListener('click', closeContextMenuOnClickOutside, { capture: true });
};
// 用于全局点击监听器的函数
const closeContextMenuOnClickOutside = (event: MouseEvent) => {
// 检查点击是否发生在菜单内部,如果是,则不关闭
// 这个检查在 TabBarContextMenu 组件内部通过 @click.stop 完成了
// 所以这里可以直接关闭
closeContextMenu();
};
// + Update function signature to receive payload
const handleContextMenuAction = (payload: { action: string; targetId: string | number | null }) => {
const { action, targetId } = payload;
console.log(`[TabBar] handleContextMenuAction received payload:`, JSON.stringify(payload)); // + Log received payload
// const targetId = contextTargetSessionId.value; // No longer needed
if (!targetId || typeof targetId !== 'string') { // Ensure targetId is a string (session ID)
console.warn('[TabBar] handleContextMenuAction called but targetId is null or not a string.');
return;
}
console.log(`[TabBar] Context menu action '${action}' requested for session ID: ${targetId}`); // Keep original log
switch (action) {
case 'close':
emit('close-session', targetId);
break;
case 'close-others':
emit('close-other-sessions', targetId);
break;
case 'close-right':
emit('close-sessions-to-right', targetId);
break;
case 'close-left':
// 注意:关闭左侧通常不包括当前标签本身
emit('close-sessions-to-left', targetId);
break;
default:
console.warn(`[TabBar] Unknown context menu action: ${action}`);
}
// closeContextMenu(); // TabBarContextMenu 内部点击后会触发 close 事件
};
// 计算右键菜单项
const contextMenuItems = computed(() => {
const items = [];
const targetId = contextTargetSessionId.value;
if (!targetId) return [];
const currentIndex = props.sessions.findIndex(s => s.sessionId === targetId);
const totalTabs = props.sessions.length;
items.push({ label: 'tabs.contextMenu.close', action: 'close' }); // 使用 i18n key
if (totalTabs > 1) {
items.push({ label: 'tabs.contextMenu.closeOthers', action: 'close-others' });
}
if (currentIndex < totalTabs - 1) {
items.push({ label: 'tabs.contextMenu.closeRight', action: 'close-right' });
}
if (currentIndex > 0) {
items.push({ label: 'tabs.contextMenu.closeLeft', action: 'close-left' });
}
return items;
});
// 新增:处理打开布局配置器的事件
const openLayoutConfigurator = () => {
console.log('[TabBar] Emitting open-layout-configurator event');
emit('open-layout-configurator'); // 发出事件
};
// --- Header Visibility Logic ---
const isWorkspaceRoute = ref(route.path === '/workspace'); // 检查是否在 /workspace 路由
// 监视路由变化
watch(() => route.path, (newPath) => {
isWorkspaceRoute.value = newPath === '/workspace';
if (isWorkspaceRoute.value) {
// 进入 /workspace 时,不需要在这里加载 Header 状态,App.vue 会处理
console.log('[TabBar] Entered /workspace route. Header toggle button is now active.');
}
});
// 组件挂载时检查一次
onMounted(() => {
isWorkspaceRoute.value = route.path === '/workspace';
if (isWorkspaceRoute.value) {
// 初始加载时,不需要在这里加载 Header 状态,App.vue 会处理
console.log('[TabBar] Mounted on /workspace route. Header toggle button is now active.');
}
});
// +++ 组件卸载前移除全局监听器 +++
// onBeforeUnmount is imported now
onBeforeUnmount(() => {
document.removeEventListener('click', closeContextMenuOnClickOutside, { capture: true });
});
// 切换主导航栏可见性 (只在 workspace 路由下生效)
// + Handler for when draggable updates the model
const handleSessionsUpdate = (newSessions: SessionTabInfoWithStatus[]) => {
// v-model handles updating draggableSessions.value automatically
emit('update:sessions', newSessions);
};
const toggleHeader = () => {
if (isWorkspaceRoute.value) {
console.log('[TabBar] Toggling header visibility');
// 调用 store action
layoutStore.toggleHeaderVisibility();
} else {
console.log('[TabBar] Not on /workspace route, toggle ignored.');
}
};
// 计算属性,用于确定眼睛图标的类
const eyeIconClass = computed(() => {
// 默认显示眼睛图标,如果主导航栏不可见,则显示斜杠眼睛
// 注意:这里假设 isHeaderVisible 为 true 时是可见的
return isHeaderVisible.value ? 'fas fa-eye' : 'fas fa-eye-slash';
});
// 计算属性,用于按钮的 title
const toggleButtonTitle = computed(() => {
// 调整 i18n key 和默认文本
return isHeaderVisible.value ? t('header.hide', '隐藏顶部导航') : t('header.show', '显示顶部导航');
});
// + Handler to hide the default drag image
const handleDragStart = (event: DragEvent) => {
if (event.dataTransfer) {
// Use a 1x1 transparent pixel as the drag image to hide the default ghost
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
};
</script>
<template>
<!-- +++ 使用 :class 绑定来条件化样式包括高度 (修正 props 引用) +++ -->
<div :class="['flex bg-header border border-border overflow-hidden',
{ 'rounded-t-md mx-2 mt-2': !props.isMobile }, // Desktop margins/rounding - Use props.isMobile
props.isMobile ? 'h-8' : 'h-10' // Mobile height h-8, Desktop h-10 - Use props.isMobile
]">
<div class="flex items-center overflow-x-auto flex-shrink min-w-0 h-full"> <!-- Ensure inner div has h-full -->
<draggable
v-model="draggableSessions"
item-key="sessionId"
tag="ul"
class="flex list-none p-0 m-0 h-full flex-shrink-0"
@update:modelValue="handleSessionsUpdate"
ghost-class="opacity-50"
drag-class="opacity-75"
animation="150"
>
<template #item="{ element: session }">
<li
:key="session.sessionId"
:class="['flex items-center px-3 h-full cursor-pointer border-r border-border transition-colors duration-150 relative group',
session.sessionId === activeSessionId ? 'bg-background text-foreground' : 'bg-header text-text-secondary hover:bg-border']"
@click="activateSession(session.sessionId)"
@contextmenu.prevent="showContextMenu($event, session.sessionId)"
@dragstart="handleDragStart"
:title="session.connectionName"
>
<!-- Status dot -->
<span :class="['w-2 h-2 rounded-full mr-2 flex-shrink-0',
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="truncate text-sm" style="transform: translateY(-1px);">{{ session.connectionName }}</span>
<button class="ml-2 p-0.5 rounded-full text-text-secondary hover:bg-border hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-150"
:class="{'text-foreground hover:bg-header': session.sessionId === activeSessionId}"
@click="closeSession($event, session.sessionId)" title="关闭标签页">
<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>
</li>
</template>
</draggable>
<!-- Add Tab Button -->
<button class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
@click="togglePopup" title="新建连接标签页">
<i class="fas fa-plus text-sm"></i>
</button>
</div>
<!-- Action Buttons -->
<div class="flex items-center ml-auto h-full flex-shrink-0">
<button
v-if="isWorkspaceRoute"
class="flex items-center justify-center px-3 h-full border-l border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150"
@click="toggleHeader"
:title="toggleButtonTitle"
>
<i :class="[eyeIconClass, 'text-sm']"></i>
</button>
<!-- +++ 使用 v-if 隐藏移动端的布局按钮 +++ -->
<button v-if="!isMobile" class="flex items-center justify-center px-3 h-full border-l border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150"
@click="openLayoutConfigurator" :title="t('layout.configure', '配置布局')">
<i class="fas fa-th-large text-sm"></i>
</button>
</div>
<!-- Connection List Popup -->
<div v-if="showConnectionListPopup" class="fixed inset-0 bg-overlay flex justify-center items-center z-50 p-4" @click.self="togglePopup">
<div class="bg-background text-foreground p-6 rounded-lg shadow-xl border border-border w-full max-w-md max-h-[80vh] flex flex-col relative">
<button class="absolute top-2 right-2 p-1 text-text-secondary hover:text-foreground" @click="togglePopup">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-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>
<h3 class="text-lg font-semibold text-center mb-4">{{ t('terminalTabBar.selectServerTitle') }}</h3>
<div class="flex-grow overflow-y-auto border border-border rounded">
<WorkspaceConnectionListComponent
@connect-request="handlePopupConnect"
@open-new-session="handlePopupConnect"
@request-add-connection="handleRequestAddFromPopup"
@request-edit-connection="handleRequestEditFromPopup"
class="popup-connection-list"
/>
</div>
</div>
</div>
<!-- +++ Context Menu Instance (Ensure it's present) +++ -->
<TabBarContextMenu
:visible="contextMenuVisible"
:position="contextMenuPosition"
:items="contextMenuItems"
:target-id="menuTargetId"
@menu-action="handleContextMenuAction"
@close="closeContextMenu"
/>
</div>
</template>