385 lines
16 KiB
Vue
385 lines
16 KiB
Vue
<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>
|