Skip to main content
Glama
MainLayout.vue20.7 kB
<template> <el-container class="main-layout"> <!-- 移动端遮罩层 --> <div v-if="isMobile && !sidebarCollapsed" class="mobile-overlay" @click="toggleSidebar" /> <!-- 侧边栏 --> <el-aside :width="sidebarWidth" class="sidebar" :class="{ 'mobile-sidebar': isMobile }" > <!-- Logo区域 --> <div class="logo" @click="goHome"> <el-icon v-if="sidebarCollapsed" class="logo-icon"> <Monitor /> </el-icon> <template v-else> <el-icon class="logo-icon"> <Monitor /> </el-icon> <span class="logo-text">MCP Gateway</span> </template> </div> <!-- 导航菜单 --> <el-scrollbar class="menu-scrollbar"> <el-menu :default-active="activeRoute" class="sidebar-menu" :collapse="sidebarCollapsed" router :unique-opened="true" > <template v-for="route in menuRoutes" :key="route.name"> <el-menu-item :index="route.path" v-if="!route.meta?.hidden"> <el-icon> <component :is="getIconComponent(route.meta?.icon)" /> </el-icon> <template #title> <span>{{ $t(`menu.${String(route.name)}`) || route.meta?.title }}</span> <el-tooltip v-if="route.meta?.description && !sidebarCollapsed" :content="route.meta.description" placement="right" > <el-icon class="menu-info"> <InfoFilled /> </el-icon> </el-tooltip> </template> </el-menu-item> </template> </el-menu> </el-scrollbar> <!-- 侧边栏底部 --> <div class="sidebar-footer"> <el-button :icon="sidebarCollapsed ? Expand : Fold" @click="toggleSidebar" text class="collapse-btn" :title="sidebarCollapsed ? t('common.expand') : t('common.collapse')" /> </div> </el-aside> <!-- 主内容区域 --> <el-container class="main-container"> <!-- 顶部导航栏 --> <el-header class="header"> <div class="header-left"> <!-- 移动端菜单按钮 --> <el-button v-if="isMobile" :icon="Menu" @click="toggleSidebar" text class="mobile-menu-btn" /> <!-- 面包屑导航 --> <el-breadcrumb separator="/" class="breadcrumb"> <el-breadcrumb-item :to="{ path: '/' }">{{ $t("breadcrumb.home") }}</el-breadcrumb-item> <el-breadcrumb-item v-if="currentRoute?.meta?.title"> {{ $t(`menu.${String(currentRoute.name)}`) || currentRoute.meta.title }} </el-breadcrumb-item> </el-breadcrumb> </div> <div class="header-right"> <!-- 系统状态指示器 --> <el-badge :value="systemStatus.errorCount" :hidden="systemStatus.errorCount === 0" class="status-badge" > <el-button :icon="systemStatus.isHealthy ? SuccessFilled : WarningFilled" :type="systemStatus.isHealthy ? 'success' : 'warning'" text @click="showSystemStatus" :title=" systemStatus.isHealthy ? $t('common.healthy') : $t('common.unhealthy') " /> </el-badge> <!-- 刷新按钮 --> <el-button :icon="Refresh" @click="refreshData" text :loading="isRefreshing" :title="$t('common.refresh')" /> <!-- 语言切换 --> <el-dropdown @command="handleLanguageChange" trigger="click"> <el-button text class="language-btn"> {{ currentLanguage.flag }} {{ currentLanguage.label }} <el-icon class="el-icon--right"><CaretBottom /></el-icon> </el-button> <template #dropdown> <el-dropdown-menu> <el-dropdown-item v-for="locale in supportedLocales" :key="locale.value" :command="locale.value" :disabled="locale.value === currentLanguage.value" > {{ locale.flag }} {{ locale.label }} </el-dropdown-item> </el-dropdown-menu> </template> </el-dropdown> <!-- 主题切换 --> <el-button :icon="isDark ? Sunny : Moon" @click="toggleTheme" text :title="isDark ? $t('theme.light') : $t('theme.dark')" /> <!-- 设置按钮 --> <el-button :icon="Setting" @click="openSettings" text :title="$t('common.settings')" /> <!-- 用户信息 --> <el-dropdown @command="handleUserAction" trigger="click" class="user-dropdown" > <div class="user-info"> <el-avatar :size="32" :src="currentUser?.avatar" class="user-avatar" > <el-icon><User /></el-icon> </el-avatar> <span v-if="!isMobile" class="username">{{ currentUser?.username }}</span> <el-icon class="el-icon--right"><CaretBottom /></el-icon> </div> <template #dropdown> <el-dropdown-menu> <el-dropdown-item command="profile"> <el-icon><User /></el-icon> {{ t("userAuth.user.profile") }} </el-dropdown-item> <el-dropdown-item command="settings"> <el-icon><Setting /></el-icon> {{ t("userAuth.user.accountSettings") }} </el-dropdown-item> <el-dropdown-item divided command="logout"> <el-icon><SwitchButton /></el-icon> {{ t("userAuth.user.logout") }} </el-dropdown-item> </el-dropdown-menu> </template> </el-dropdown> </div> </el-header> <!-- 主内容 --> <el-main class="main-content"> <router-view v-slot="{ Component, route }"> <transition name="fade-slide" mode="out-in"> <component :is="Component" :key="route.path" /> </transition> </router-view> </el-main> </el-container> </el-container> </template> <script setup lang="ts"> import { ref, computed, onMounted, onUnmounted, inject } from "vue"; import { useRoute, useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { useI18n } from "vue-i18n"; import { Monitor, HelpFilled as Server, Document, Tools, Lock, Setting, List, ChatDotRound, Expand, Fold, Refresh, Menu, InfoFilled, SuccessFilled, WarningFilled, Sunny, Moon, CaretBottom, User, SwitchButton, } from "@element-plus/icons-vue"; import { useAppStore } from "@/stores/app"; import { useThemeStore } from "@/stores/theme"; import { useLocaleStore } from "@/stores/locale"; import { useAuthStore } from "@/stores/auth"; import { SUPPORT_LOCALES, type Locale } from "@/locales"; // 国际化 const { t } = useI18n(); // 全局功能注入 const globalErrorHandler = inject("$globalErrorHandler") as any; // 简化的性能监控函数 const measureFunction = ( fn: () => void | Promise<void>, name: string, metadata?: any, ) => { const startTime = performance.now(); try { const result = fn(); if (result instanceof Promise) { return result.finally(() => { const duration = performance.now() - startTime; console.log( `[Performance] ${name}: ${duration.toFixed(2)}ms`, metadata, ); }); } else { const duration = performance.now() - startTime; console.log(`[Performance] ${name}: ${duration.toFixed(2)}ms`, metadata); return result; } } catch (error) { const duration = performance.now() - startTime; console.error( `[Performance] ${name} failed after ${duration.toFixed(2)}ms`, error, metadata, ); throw error; } }; // 路由和状态管理 const route = useRoute(); const router = useRouter(); const appStore = useAppStore(); const themeStore = useThemeStore(); const localeStore = useLocaleStore(); const authStore = useAuthStore(); // 响应式状态 const sidebarCollapsed = ref(false); const isMobile = ref(false); const isRefreshing = ref(false); // 系统状态 const systemStatus = ref({ isHealthy: true, errorCount: 0, lastUpdate: new Date(), }); // 计算属性 const activeRoute = computed(() => { // 如果当前路由有parent属性,则激活parent对应的菜单项 if (route.meta?.parent) { return route.meta.parent; } // 否则返回当前路径 return route.path; }); const currentRoute = computed(() => route); const isDark = computed(() => themeStore.isDark); const currentLanguage = computed(() => localeStore.currentLanguage); const supportedLocales = computed(() => SUPPORT_LOCALES); const currentUser = computed(() => authStore.currentUser); const sidebarWidth = computed(() => { if (isMobile.value) { return sidebarCollapsed.value ? "0px" : "240px"; } return sidebarCollapsed.value ? "64px" : "240px"; }); // 获取菜单路由 const menuRoutes = computed(() => { const mainRoute = router.getRoutes().find((r) => r.path === "/"); return mainRoute?.children || []; }); // 图标组件映射 const iconComponents = { Monitor, Server, Document, Tools, Lock, Setting, List, ChatDotRound, }; // 方法 const getIconComponent = (iconName?: string | unknown) => { if ( !iconName || typeof iconName !== "string" || !iconComponents[iconName as keyof typeof iconComponents] ) { return Monitor; } return iconComponents[iconName as keyof typeof iconComponents]; }; const toggleSidebar = () => { sidebarCollapsed.value = !sidebarCollapsed.value; // 在移动端,保存侧边栏状态到本地存储 if (isMobile.value) { localStorage.setItem( "sidebar-collapsed-mobile", String(sidebarCollapsed.value), ); } else { localStorage.setItem("sidebar-collapsed", String(sidebarCollapsed.value)); } }; const goHome = () => { if (route.path !== "/servers") { router.push("/servers"); } }; const refreshData = async () => { isRefreshing.value = true; try { await measureFunction( async () => { await appStore.refreshData(); }, "layout-refresh-data", { currentRoute: route.path }, ); ElMessage.success(t("common.refreshSuccess")); } catch (error: any) { globalErrorHandler?.captureError(error, { context: "layout-refresh-data", currentRoute: route.path, action: "refresh_app_data", }); ElMessage.error(t("common.refreshFailed")); } finally { isRefreshing.value = false; } }; const toggleTheme = () => { try { measureFunction( () => { themeStore.toggleTheme(); }, "layout-toggle-theme", { newTheme: !themeStore.isDark ? "dark" : "light" }, ); ElMessage.success( t("theme.switched", { theme: themeStore.isDark ? t("theme.dark") : t("theme.light"), }), ); } catch (error: any) { globalErrorHandler?.captureError(error, { context: "layout-toggle-theme", action: "theme_toggle", }); ElMessage.error(t("error.operationFailed")); } }; const handleLanguageChange = (locale: Locale) => { try { measureFunction( () => { localeStore.changeLocale(locale); }, "layout-change-language", { newLocale: locale }, ); const language = SUPPORT_LOCALES.find((l) => l.value === locale); ElMessage.success( t("language.switched", { language: language?.label || locale }), ); } catch (error: any) { globalErrorHandler?.captureError(error, { context: "layout-change-language", action: "language_change", }); ElMessage.error(t("error.operationFailed")); } }; const openSettings = () => { try { measureFunction(() => { // TODO: 实现设置对话框 ElMessage.info(t("common.info")); }, "layout-open-settings"); } catch (error: any) { globalErrorHandler?.captureError(error, { context: "layout-open-settings", action: "open_settings", }); } }; const showSystemStatus = () => { try { if (systemStatus.value.isHealthy) { ElMessage.success( t("dashboard.systemStatus") + ": " + t("common.healthy"), ); } else { ElMessage.warning( t("dashboard.systemStatus") + ": " + t("common.unhealthy"), ); } } catch (error: any) { globalErrorHandler?.captureError(error, { context: "layout-show-system-status", action: "show_status", }); } }; // 处理用户操作 const handleUserAction = async (command: string) => { try { switch (command) { case "profile": // TODO: 打开个人资料页面 ElMessage.info(t("userAuth.messages.profileInDevelopment")); break; case "settings": // TODO: 打开账户设置页面 ElMessage.info(t("userAuth.messages.settingsInDevelopment")); break; case "logout": await measureFunction(async () => { await authStore.logout(); router.push("/login"); }, "layout-user-logout"); break; default: console.warn("Unknown user action:", command); } } catch (error: any) { globalErrorHandler?.captureError(error, { context: "layout-user-action", action: command, }); ElMessage.error(t("userAuth.errors.operationFailed")); } }; // 检测屏幕尺寸 const checkScreenSize = () => { isMobile.value = window.innerWidth < 768; // 移动端默认收起侧边栏 if (isMobile.value) { const savedState = localStorage.getItem("sidebar-collapsed-mobile"); sidebarCollapsed.value = savedState ? savedState === "true" : true; } else { const savedState = localStorage.getItem("sidebar-collapsed"); sidebarCollapsed.value = savedState ? savedState === "true" : false; } }; // 更新系统状态 const updateSystemStatus = () => { // 这里可以从store或API获取实际的系统状态 systemStatus.value = { isHealthy: appStore.isHealthy, errorCount: appStore.systemHealth.errorCount, lastUpdate: new Date(), }; }; // 生命周期 onMounted(() => { checkScreenSize(); updateSystemStatus(); // 监听窗口大小变化 window.addEventListener("resize", checkScreenSize); // 定期更新系统状态 const statusInterval = setInterval(updateSystemStatus, 30000); // 30秒更新一次 // 清理函数 onUnmounted(() => { window.removeEventListener("resize", checkScreenSize); clearInterval(statusInterval); }); }); </script> <style scoped> .main-layout { height: 100vh; overflow: hidden; } /* 移动端遮罩层 */ .mobile-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); z-index: 1000; transition: opacity 0.3s; } /* 侧边栏样式 */ .sidebar { background: linear-gradient(180deg, #304156 0%, #263445 100%); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); position: relative; z-index: 1001; } .mobile-sidebar { position: fixed; top: 0; left: 0; height: 100vh; z-index: 1001; } /* Logo区域 */ .logo { height: 64px; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; border-bottom: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; transition: background-color 0.3s; gap: 12px; } .logo:hover { background-color: rgba(255, 255, 255, 0.05); } .logo-icon { font-size: 24px; color: #409eff; } .logo-text { font-size: 18px; font-weight: 600; letter-spacing: 0.5px; } /* 菜单滚动区域 */ .menu-scrollbar { height: calc(100vh - 64px - 60px); } /* 侧边栏菜单 */ .sidebar-menu { border: none; background-color: transparent; width: 100%; } .sidebar-menu .el-menu-item { color: rgba(255, 255, 255, 0.8); border-radius: 8px; margin: 4px 8px; transition: all 0.3s; position: relative; } .sidebar-menu .el-menu-item:hover { background: linear-gradient( 90deg, rgba(64, 158, 255, 0.1) 0%, rgba(64, 158, 255, 0.05) 100% ); color: #409eff; transform: translateX(4px); } .sidebar-menu .el-menu-item.is-active { background: linear-gradient(90deg, #409eff 0%, #337ecc 100%); color: white; box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3); } .sidebar-menu .el-menu-item.is-active::before { content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 4px; height: 20px; background-color: white; border-radius: 0 2px 2px 0; } .menu-info { margin-left: auto; opacity: 0.6; font-size: 14px; } /* 侧边栏底部 */ .sidebar-footer { position: absolute; bottom: 0; left: 0; right: 0; height: 60px; display: flex; align-items: center; justify-content: center; border-top: 1px solid rgba(255, 255, 255, 0.1); } .collapse-btn { color: rgba(255, 255, 255, 0.8); font-size: 18px; } .collapse-btn:hover { color: #409eff; background-color: rgba(255, 255, 255, 0.1); } /* 主容器 */ .main-container { flex: 1; overflow: hidden; } /* 顶部导航栏 */ .header { background: linear-gradient(90deg, #ffffff 0%, #f8f9fa 100%); border-bottom: 1px solid #e4e7ed; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); position: relative; z-index: 100; } .header-left, .header-right { display: flex; align-items: center; gap: 12px; } .mobile-menu-btn { font-size: 20px; margin-right: 8px; } .breadcrumb { font-size: 14px; } .status-badge { margin-right: 4px; } .language-btn { min-width: auto !important; padding: 8px 12px !important; font-size: 13px; display: flex; align-items: center; gap: 4px; } .language-btn .el-icon--right { margin-left: 2px !important; } /* 用户信息样式 */ .user-dropdown { margin-left: 8px; } .user-info { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 6px; cursor: pointer; transition: all 0.2s; } .user-info:hover { background-color: rgba(0, 0, 0, 0.05); } .user-avatar { flex-shrink: 0; } .username { font-size: 14px; font-weight: 500; color: #303133; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .user-info .el-icon--right { margin-left: 4px; font-size: 12px; color: #909399; } /* 主内容区域 */ .main-content { background-color: #f5f7fa; padding: 24px; overflow-y: auto; height: calc(100vh - 64px); } /* 页面切换动画 */ .fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .fade-slide-enter-from { opacity: 0; transform: translateX(20px); } .fade-slide-leave-to { opacity: 0; transform: translateX(-20px); } /* 响应式设计 */ @media (max-width: 768px) { .header { padding: 0 16px; } .main-content { padding: 16px; } .breadcrumb { display: none; } .header-right { gap: 8px; } .header-right .el-button { padding: 8px; } } @media (max-width: 480px) { .header-right { gap: 4px; } .main-content { padding: 12px; } } /* 暗色主题支持 */ .dark .sidebar { background: linear-gradient(180deg, #1f2937 0%, #111827 100%); } .dark .header { background: linear-gradient(90deg, #1f2937 0%, #374151 100%); border-bottom-color: #374151; color: #f9fafb; } .dark .main-content { background-color: #111827; color: #f9fafb; } .dark .logo { border-bottom-color: rgba(255, 255, 255, 0.1); } .dark .sidebar-footer { border-top-color: rgba(255, 255, 255, 0.1); } .dark .user-info:hover { background-color: rgba(255, 255, 255, 0.1); } .dark .username { color: #f9fafb; } .dark .user-info .el-icon--right { color: #9ca3af; } </style>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/zaizaizhao/mcp-swagger-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server