Files
nexus-terminal/packages/frontend/src/App.vue
T
Baobhan Sith 90db1e218f feat: 添加连接管理路由
Related to #35
2025-05-16 12:42:07 +08:00

356 lines
19 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 { RouterLink, RouterView, useRoute } from 'vue-router';
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from './stores/auth.store';
import { useDeviceDetection } from './composables/useDeviceDetection';
import { useSettingsStore } from './stores/settings.store';
import { useAppearanceStore } from './stores/appearance.store';
import { useLayoutStore } from './stores/layout.store';
import { useFocusSwitcherStore } from './stores/focusSwitcher.store';
import { useSessionStore } from './stores/session.store';
import { storeToRefs } from 'pinia';
import UINotificationDisplay from './components/UINotificationDisplay.vue';
import FileEditorOverlay from './components/FileEditorOverlay.vue';
import StyleCustomizer from './components/StyleCustomizer.vue';
import FocusSwitcherConfigurator from './components/FocusSwitcherConfigurator.vue';
import RemoteDesktopModal from './components/RemoteDesktopModal.vue';
import VncModal from './components/VncModal.vue';
const { t } = useI18n();
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const appearanceStore = useAppearanceStore();
const layoutStore = useLayoutStore();
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
const sessionStore = useSessionStore(); // +++ 实例化 Session Store +++
const { isAuthenticated } = storeToRefs(authStore);
const { showPopupFileEditorBoolean } = storeToRefs(settingsStore);
const { isStyleCustomizerVisible } = storeToRefs(appearanceStore);
const { isLayoutVisible, isHeaderVisible } = storeToRefs(layoutStore); // 添加 isHeaderVisible
const { isConfiguratorVisible: isFocusSwitcherVisible } = storeToRefs(focusSwitcherStore);
const { isRdpModalOpen, rdpConnectionInfo, isVncModalOpen, vncConnectionInfo } = storeToRefs(sessionStore); // +++ 获取 RDP 和 VNC 状态 +++
const { isMobile } = useDeviceDetection();
const route = useRoute();
const navRef = ref<HTMLElement | null>(null);
const underlineRef = ref<HTMLElement | null>(null);
// +++ 存储上一次由切换器聚焦的 ID +++
const lastFocusedIdBySwitcher = ref<string | null>(null);
const isAltPressed = ref(false); // 跟踪 Alt 键是否按下
const altShortcutKey = ref<string | null>(null);
// --- 移除 shortcutTriggeredInKeyDown 标志 ---
const updateUnderline = async () => {
await nextTick(); // 等待 DOM 更新
if (navRef.value && underlineRef.value) {
const activeLink = navRef.value.querySelector('.router-link-exact-active') as HTMLElement;
if (activeLink) {
const offsetBottom = 2; // 下划线距离文字底部的距离 (px)
underlineRef.value.style.left = `${activeLink.offsetLeft}px`;
underlineRef.value.style.width = `${activeLink.offsetWidth}px`;
// underlineRef.value.style.top = `${activeLink.offsetTop + activeLink.offsetHeight + offsetBottom}px`; // 移除 top 设置
underlineRef.value.style.opacity = '1'; // Make it visible
} else {
underlineRef.value.style.opacity = '0'; // Hide if no active link (e.g., on login page if not a nav link)
}
}
};
onMounted(() => {
// Initial position update
// Use setTimeout to ensure styles are applied and elements have dimensions
setTimeout(updateUnderline, 100);
// +++ 全局 Alt 键监听器 +++
window.addEventListener('keydown', handleAltKeyDown); // +++ 监听 keydown 设置状态 +++
window.addEventListener('keyup', handleGlobalKeyUp); // +++ 监听 keyup 执行切换 +++
// PWA Install Prompt
window.addEventListener('beforeinstallprompt', (e) => {
console.log('[App.vue] beforeinstallprompt event fired. Browser will handle install prompt.');
});
window.addEventListener('appinstalled', () => {
console.log('[App.vue] PWA was installed');
});
// +++ 加载 Header 可见性状态 +++
layoutStore.loadHeaderVisibility();
});
// +++ 卸载钩子以移除监听器 +++
onUnmounted(() => {
window.removeEventListener('keydown', handleAltKeyDown); // +++ 移除 keydown 监听 +++
window.removeEventListener('keyup', handleGlobalKeyUp); // +++ 移除 keyup 监听 +++
});
// *** 计算属性,判断是否在 workspace 路由 ***
const isWorkspaceRoute = computed(() => route.path === '/workspace');
watch(route, () => {
updateUnderline();
}, { immediate: true }); // *** 确保 immediate: true 存在 ***
const handleLogout = () => {
authStore.logout();
};
// 打开样式自定义器的方法现在直接调用 store action
const openStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(true);
};
// 关闭样式自定义器的方法现在也调用 store action
const closeStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(false);
};
// +++ 修改:处理 Alt 键按下的事件处理函数,并记录快捷键 +++
const handleAltKeyDown = async (event: KeyboardEvent) => { // +++ 改为 async +++
// 只在 Alt 键首次按下时设置状态
if (event.key === 'Alt' && !event.repeat) {
isAltPressed.value = true;
altShortcutKey.value = null;
// console.log('[App] Alt key pressed down.');
} else if (isAltPressed.value && !['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) {
// 如果 Alt 正被按住,且按下了非修饰键 (移除 !shortcutTriggeredInKeyDown 检查)
let key = event.key;
if (key.length === 1) key = key.toUpperCase();
if (/^[a-zA-Z0-9]$/.test(key)) {
altShortcutKey.value = key; // 记录按键
const shortcutString = `Alt+${key}`;
console.log(`[App] KeyDown: Alt+${key} detected. Checking shortcut: ${shortcutString}`);
const targetId = focusSwitcherStore.getFocusTargetIdByShortcut(shortcutString);
if (targetId) {
console.log(`[App] KeyDown: Shortcut match found. Targeting ID: ${targetId}`);
event.preventDefault(); // 阻止默认行为 (如菜单)
const success = await focusSwitcherStore.focusTarget(targetId); // +++ 立即尝试聚焦 +++
if (success) {
console.log(`[App] KeyDown: Successfully focused ${targetId} via shortcut.`);
lastFocusedIdBySwitcher.value = targetId;
// --- 移除设置标志位 ---
} else {
console.log(`[App] KeyDown: Failed to focus ${targetId} via shortcut action.`);
// 聚焦失败,可以选择是否取消 Alt 状态,暂时不处理,让 keyup 重置
}
} else {
console.log(`[App] KeyDown: No configured shortcut found for ${shortcutString}.`);
// 没有匹配的快捷键,可以选择取消 Alt 状态以允许默认行为,或保持状态等待 keyup
// isAltPressed.value = false;
// altShortcutKey.value = null;
}
} else {
// 按下无效键 (非字母数字),取消 Alt 状态
isAltPressed.value = false;
altShortcutKey.value = null;
// --- 移除重置标志位 ---
console.log('[App] KeyDown: Alt sequence cancelled by non-alphanumeric key press.');
}
} else if (isAltPressed.value && ['Control', 'Shift', 'Meta'].includes(event.key)) {
// 按下其他修饰键,取消 Alt 状态
isAltPressed.value = false;
altShortcutKey.value = null;
// --- 移除重置标志位 ---
console.log('[App] KeyDown: Alt sequence cancelled by other modifier key press.');
}
};
// +++ 修改:全局键盘事件处理函数,监听 keyup,优先处理快捷键 +++
const handleGlobalKeyUp = async (event: KeyboardEvent) => {
if (event.key === 'Alt') {
const altWasPressed = isAltPressed.value;
const triggeredShortcutKey = altShortcutKey.value; // 记录松开时是否有记录的快捷键
// 总是重置状态
isAltPressed.value = false;
altShortcutKey.value = null;
// --- 移除重置标志位 ---
if (altWasPressed && triggeredShortcutKey === null) {
// 如果 Alt 之前是按下的,并且没有记录到有效的快捷键,则执行顺序切换
console.log('[App] KeyUp: Alt released without a valid shortcut key captured. Attempting sequential focus switch.');
event.preventDefault(); // 仅在执行顺序切换时阻止默认行为
// --- 顺序切换逻辑 (保持不变) ---
let currentFocusId: string | null = lastFocusedIdBySwitcher.value;
console.log(`[App] Sequential switch. Last focused by switcher: ${currentFocusId}`);
if (!currentFocusId) {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && activeElement.hasAttribute('data-focus-id')) {
currentFocusId = activeElement.getAttribute('data-focus-id');
console.log(`[App] Sequential switch. Found focus ID from activeElement: ${currentFocusId}`);
} else {
console.log(`[App] Sequential switch. Could not determine current focus ID.`);
}
}
const order = focusSwitcherStore.sequenceOrder; // ++ 使用新的 sequenceOrder state ++
if (order.length === 0) { // ++ 检查新的 state ++
console.log('[App] No focus sequence configured.');
return;
}
let focused = false;
for (let i = 0; i < order.length; i++) { // ++ Use order.length for loop condition ++
const nextFocusId = focusSwitcherStore.getNextFocusTargetId(currentFocusId);
if (!nextFocusId) {
console.warn('[App] Could not determine next focus target ID in sequence.');
break;
}
console.log(`[App] Sequential switch. Trying to focus target ID: ${nextFocusId}`);
const success = await focusSwitcherStore.focusTarget(nextFocusId);
if (success) {
console.log(`[App] Successfully focused ${nextFocusId} sequentially.`);
lastFocusedIdBySwitcher.value = nextFocusId;
focused = true;
break;
} else {
console.log(`[App] Failed to focus ${nextFocusId} sequentially. Trying next...`);
currentFocusId = nextFocusId;
}
}
if (!focused) {
console.log('[App] Cycled through sequence, no target could be focused.');
lastFocusedIdBySwitcher.value = null;
}
// --- 顺序切换逻辑结束 ---
} else if (altWasPressed && triggeredShortcutKey !== null) {
console.log(`[App] KeyUp: Alt released after capturing key '${triggeredShortcutKey}'. Shortcut logic handled in keydown. No sequential switch.`);
// 快捷键逻辑已在 keydown 处理,keyup 时无需操作,也不阻止默认行为(除非特定需要)
} else {
// Alt 松开,但 isAltPressed 已经是 false (例如被其他键取消了)
console.log('[App] KeyUp: Alt released, but sequence was already cancelled or not active.');
}
}
};
// +++ 辅助函数:检查元素是否可见且可聚焦 +++
const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
if (!element) return false;
// 检查元素是否在 DOM 中,并且没有 display: none
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden') return false;
// 检查元素或其父元素是否被禁用
if ((element as HTMLInputElement).disabled) return false;
let parent = element.parentElement;
while (parent) {
if ((parent as HTMLFieldSetElement).disabled) return false;
parent = parent.parentElement;
}
// 检查元素是否足够在视口内(粗略检查)
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
</script>
<template>
<div id="app-container">
<!-- *** 修改 v-if 条件以使用 isHeaderVisible *** -->
<!-- Header with Tailwind classes using theme variables -->
<header v-if="!isWorkspaceRoute || isHeaderVisible" class="sticky top-0 z-10 flex items-center h-14 pl-3 pr-6 bg-header border-b border-border shadow-sm"> <!-- 减少左侧内边距 -->
<!-- Nav with Tailwind classes -->
<nav ref="navRef" class="flex items-center justify-between w-full relative"> <!-- Added relative positioning for underline -->
<!-- Left navigation links with Tailwind classes using theme variables -->
<div class="flex items-center space-x-1">
<!-- 项目 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="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="/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> <!-- 移动端隐藏 -->
<RouterLink to="/settings" 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.settings') }}</RouterLink> <!-- 保持可见 -->
</div>
<!-- Right navigation links with Tailwind classes using theme variables -->
<div class="flex items-center space-x-1">
<!-- GitHub Icon (Hide on mobile) -->
<a v-if="!isMobile" href="https://github.com/Heavrnl/nexus-terminal" target="_blank" rel="noopener noreferrer" title="Heavrnl/nexus-terminal" class="px-2 py-2 rounded-md text-lg text-icon hover:text-icon-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
</svg>
</a>
<!-- PWA Install Button - REMOVED FROM HERE -->
<a href="#" @click.prevent="openStyleCustomizer" :title="t('nav.customizeStyle')" class="px-2 py-2 rounded-md text-lg text-icon hover:text-icon-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out"><i class="fas fa-paint-brush"></i></a>
<RouterLink v-if="!isAuthenticated" to="/login" class="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">{{ t('nav.login') }}</RouterLink>
<a href="#" v-if="isAuthenticated" @click.prevent="handleLogout" class="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">{{ t('nav.logout') }}</a>
</div>
<!-- Sliding underline element with Tailwind classes using theme variables (JS still controls positioning) -->
<div ref="underlineRef" class="absolute bottom-0 h-0.5 bg-link-active rounded transition-all duration-300 ease-in-out pointer-events-none opacity-0 transform translate-y-1.5"></div> <!-- Changed translate-y-1 to translate-y-1.5 -->
</nav>
</header>
<main>
<!-- 使用 KeepAlive 包裹 RouterView并指定缓存 WorkspaceView -->
<RouterView v-slot="{ Component }">
<KeepAlive include="WorkspaceView">
<component :is="Component" />
</KeepAlive>
</RouterView>
</main>
<!-- 添加全局通知显示 -->
<UINotificationDisplay />
<!-- 根据设置条件渲染全局文件编辑器弹窗 -->
<FileEditorOverlay v-if="showPopupFileEditorBoolean" :is-mobile="isMobile" />
<!-- 条件渲染样式自定义器使用 store 的状态和方法 -->
<StyleCustomizer v-if="isStyleCustomizerVisible" @close="closeStyleCustomizer" />
<!-- +++ 条件渲染焦点切换配置器 (使用 v-show 保持实例) +++ -->
<FocusSwitcherConfigurator
v-show="isFocusSwitcherVisible"
:isVisible="isFocusSwitcherVisible"
@close="focusSwitcherStore.toggleConfigurator(false)"
/>
<!-- +++ 条件渲染 RDP 模态框 +++ -->
<RemoteDesktopModal
v-if="isRdpModalOpen"
:connection="rdpConnectionInfo"
@close="sessionStore.closeRdpModal()"
/>
<!-- +++ 条件渲染 VNC 模态框 +++ -->
<VncModal
v-if="isVncModalOpen"
:connection="vncConnectionInfo"
@close="sessionStore.closeVncModal()"
/>
</div>
</template>
<style scoped>
#app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
font-family: var(--font-family-sans-serif); /* 使用字体变量 */
}
main {
flex-grow: 1;
}
</style>