Skip to main content
Glama
Login.vue21.7 kB
<template> <div class="login-container"> <!-- AI网关背景装饰 --> <div class="background-decoration"> <div class="circuit-pattern"></div> <div class="floating-nodes"> <div class="node" v-for="i in 12" :key="i" :style="getNodeStyle(i)" ></div> </div> </div> <div class="login-card"> <!-- 顶部工具栏 --> <div class="login-toolbar"> <!-- 语言切换 --> <el-dropdown @command="handleLanguageChange" trigger="click" size="small" > <el-button text class="toolbar-btn"> {{ localeStore.currentLanguage.flag }} <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=" localeStore.currentLanguage && locale.value === localeStore.currentLanguage.value " > {{ locale.flag }} {{ locale.label }} </el-dropdown-item> </el-dropdown-menu> </template> </el-dropdown> </div> <!-- Logo和标题区域 --> <div class="login-header"> <div class="logo-container"> <div class="logo"> <svg viewBox="0 0 100 100" class="logo-icon"> <!-- AI网关Logo设计 --> <defs> <linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%" > <stop offset="0%" style="stop-color: #667eea; stop-opacity: 1" /> <stop offset="100%" style="stop-color: #764ba2; stop-opacity: 1" /> </linearGradient> <filter id="glow"> <feGaussianBlur stdDeviation="3" result="coloredBlur" /> <feMerge> <feMergeNode in="coloredBlur" /> <feMergeNode in="SourceGraphic" /> </feMerge> </filter> </defs> <!-- 外圈 --> <circle cx="50" cy="50" r="45" fill="none" stroke="url(#logoGradient)" stroke-width="2" opacity="0.3" /> <!-- 内部网络节点 --> <circle cx="30" cy="30" r="4" fill="url(#logoGradient)" filter="url(#glow)" /> <circle cx="70" cy="30" r="4" fill="url(#logoGradient)" filter="url(#glow)" /> <circle cx="50" cy="50" r="6" fill="url(#logoGradient)" filter="url(#glow)" /> <circle cx="30" cy="70" r="4" fill="url(#logoGradient)" filter="url(#glow)" /> <circle cx="70" cy="70" r="4" fill="url(#logoGradient)" filter="url(#glow)" /> <!-- 连接线 --> <line x1="30" y1="30" x2="50" y2="50" stroke="url(#logoGradient)" stroke-width="2" opacity="0.6" /> <line x1="70" y1="30" x2="50" y2="50" stroke="url(#logoGradient)" stroke-width="2" opacity="0.6" /> <line x1="30" y1="70" x2="50" y2="50" stroke="url(#logoGradient)" stroke-width="2" opacity="0.6" /> <line x1="70" y1="70" x2="50" y2="50" stroke="url(#logoGradient)" stroke-width="2" opacity="0.6" /> <!-- AI符号 --> <text x="50" y="55" text-anchor="middle" fill="url(#logoGradient)" font-size="12" font-weight="bold" font-family="Arial, sans-serif" > AI </text> </svg> </div> <div class="brand-text"> <h1 class="brand-title">MCP Gateway</h1> <p class="brand-subtitle">AI-Powered API Gateway</p> </div> </div> <div class="login-title-section"> <h2 class="login-title">{{ $t("userAuth.login.title") }}</h2> <p class="login-subtitle">{{ $t("userAuth.login.subtitle") }}</p> </div> </div> <div class="login-form"> <form @submit.prevent="handleSubmit"> <div class="form-group"> <label for="username" class="form-label"> <svg class="label-icon" viewBox="0 0 24 24"> <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /> </svg> {{ $t("userAuth.login.usernameOrEmail") }} </label> <input id="username" v-model="form.username" type="text" class="form-input" :class="{ error: errors.username }" :placeholder="$t('userAuth.login.enterUsername')" required :disabled="authLoading" /> <span v-if="errors.username" class="error-message">{{ errors.username }}</span> </div> <div class="form-group"> <label for="password" class="form-label"> <svg class="label-icon" viewBox="0 0 24 24"> <path d="M18,8h-1V6c0-2.76-2.24-5-5-5S7,3.24,7,6v2H6c-1.1,0-2,0.9-2,2v10c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2V10C20,8.9,19.1,8,18,8z M12,17c-1.1,0-2-0.9-2-2s0.9-2,2-2s2,0.9,2,2S13.1,17,12,17z M15.1,8H8.9V6c0-1.71,1.39-3.1,3.1-3.1s3.1,1.39,3.1,3.1V8z" /> </svg> {{ $t("userAuth.login.password") }} </label> <div class="password-input-wrapper"> <input id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'" class="form-input" :class="{ error: errors.password }" :placeholder="$t('userAuth.login.enterPassword')" required :disabled="authLoading" /> <button type="button" class="password-toggle" @click="showPassword = !showPassword" :disabled="authLoading" > <svg v-if="showPassword" class="icon" viewBox="0 0 24 24"> <path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" /> </svg> <svg v-else class="icon" viewBox="0 0 24 24"> <path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" /> </svg> </button> </div> <span v-if="errors.password" class="error-message">{{ errors.password }}</span> </div> <div class="form-options"> <label class="checkbox-wrapper"> <input v-model="form.rememberMe" type="checkbox" class="checkbox" :disabled="authLoading" /> <span class="checkbox-label">{{ $t("userAuth.login.rememberMe") }}</span> </label> </div> <button type="submit" class="login-button" :disabled="authLoading || !isFormValid" > <svg v-if="authLoading" class="loading-icon" viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" opacity="0.25" /> <path d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" fill="currentColor" /> </svg> <span v-if="!authLoading">{{ $t("userAuth.login.loginButton") }}</span> <span v-else>{{ $t("userAuth.login.loggingIn") }}</span> </button> </form> </div> <!-- 错误提示 --> <div v-if="authError" class="error-alert"> <svg class="error-icon" viewBox="0 0 24 24"> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /> </svg> <span>{{ authError }}</span> <button @click="clearAuthError" class="error-close"> <svg viewBox="0 0 24 24"> <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" /> </svg> </button> </div> </div> </div> </template> <script setup lang="ts"> import { ref, computed, onMounted, nextTick } from "vue"; import { useRouter } from "vue-router"; import { useI18n } from "vue-i18n"; import { ElMessage } from "element-plus"; import { CaretBottom } from "@element-plus/icons-vue"; import { useAuthStore } from "@/stores/auth"; import { useLocaleStore } from "@/stores/locale"; import { SUPPORT_LOCALES, type Locale } from "@/locales"; import type { LoginCredentials } from "@/types"; const router = useRouter(); const authStore = useAuthStore(); const localeStore = useLocaleStore(); const { t } = useI18n(); // 响应式数据 const form = ref<LoginCredentials & { rememberMe: boolean }>({ username: "", password: "", rememberMe: false, }); const showPassword = ref(false); const errors = ref<Record<string, string>>({}); // 计算属性 const { authLoading, authError, isAuthenticated } = authStore; const { clearAuthError } = authStore; const supportedLocales = SUPPORT_LOCALES; const isFormValid = computed(() => { return ( form.value.username.trim() !== "" && form.value.password.trim() !== "" && form.value.password.length >= 6 ); }); // 生成浮动节点样式 const getNodeStyle = (index: number) => { const angle = (index * 30) % 360; const radius = 20 + (index % 3) * 15; const x = Math.cos((angle * Math.PI) / 180) * radius; const y = Math.sin((angle * Math.PI) / 180) * radius; const delay = index * 0.5; return { left: `calc(50% + ${x}px)`, top: `calc(50% + ${y}px)`, animationDelay: `${delay}s`, }; }; // 表单验证 const validateForm = (): boolean => { errors.value = {}; if (!form.value.username.trim()) { errors.value.username = t("userAuth.validation.usernameRequired"); } if (!form.value.password.trim()) { errors.value.password = t("userAuth.validation.passwordRequired"); } else if (form.value.password.length < 6) { errors.value.password = t("userAuth.validation.passwordMinLength"); } return Object.keys(errors.value).length === 0; }; // 处理表单提交 const handleSubmit = async () => { if (!validateForm()) { return; } const success = await authStore.login({ username: form.value.username, password: form.value.password, }); console.log("success", success); if (success) { // 登录成功,等待一个tick确保状态完全更新后再跳转 await nextTick(); console.log(authStore.isAuthenticated); // 再次确认认证状态 if (authStore.isAuthenticated) { const redirect = router.currentRoute.value.query.redirect as string; router.push(redirect || "/servers"); } else { console.warn("登录成功但认证状态未正确设置"); } } }; // 语言切换 const handleLanguageChange = (locale: string) => { try { const targetLocale = locale as Locale; localeStore.changeLocale(targetLocale); const language = SUPPORT_LOCALES.find((l) => l.value === targetLocale); ElMessage.success( t("language.switched", { language: language?.label || targetLocale }), ); } catch (error: any) { ElMessage.error(t("error.operationFailed")); } }; // 生命周期 onMounted(() => { // 如果已经登录,直接跳转 if (isAuthenticated) { router.push("/servers"); } // 清除之前的错误 clearAuthError(); }); </script> <style scoped> .login-container { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient( 180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100% ); padding: 1rem; position: relative; overflow: hidden; } .background-decoration { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 1; } .circuit-pattern { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-image: radial-gradient( circle at 25% 25%, rgba(0, 122, 255, 0.1) 1px, transparent 1px ), radial-gradient( circle at 75% 75%, rgba(0, 122, 255, 0.1) 1px, transparent 1px ), linear-gradient( 45deg, transparent 40%, rgba(0, 122, 255, 0.05) 50%, transparent 60% ); background-size: 50px 50px, 50px 50px, 100px 100px; animation: circuitMove 20s linear infinite; } @keyframes circuitMove { 0% { transform: translate(0, 0); } 100% { transform: translate(50px, 50px); } } .floating-nodes { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } .node { position: absolute; width: 4px; height: 4px; background: rgba(0, 122, 255, 0.6); border-radius: 50%; animation: float 6s ease-in-out infinite; } @keyframes float { 0%, 100% { transform: translateY(0px) scale(1); opacity: 0.6; } 50% { transform: translateY(-20px) scale(1.2); opacity: 1; } } .login-card { background: var(--bg-primary); backdrop-filter: blur(20px) saturate(180%); border-radius: var(--radius-xl); border: 1px solid var(--border-color); box-shadow: var(--shadow-heavy); width: 100%; max-width: 450px; padding: 2.5rem; position: relative; z-index: 2; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .login-toolbar { position: absolute; top: 1rem; right: 1rem; display: flex; align-items: center; gap: 0.5rem; z-index: 10; } .toolbar-btn { min-width: auto !important; padding: 6px 8px !important; font-size: 14px; color: var(--text-secondary); transition: all 0.2s; border-radius: var(--radius-medium); } .toolbar-btn:hover { color: var(--apple-blue); background-color: rgba(0, 122, 255, 0.1); } .login-header { text-align: center; margin-bottom: 2.5rem; } .logo-container { display: flex; flex-direction: column; align-items: center; margin-bottom: 2rem; } .logo { margin-bottom: 1rem; } .logo-icon { width: 80px; height: 80px; animation: logoGlow 3s ease-in-out infinite; } @keyframes logoGlow { 0%, 100% { filter: drop-shadow(0 0 10px rgba(0, 122, 255, 0.3)); } 50% { filter: drop-shadow(0 0 20px rgba(0, 122, 255, 0.6)); } } .brand-text { text-align: center; } .brand-title { font-size: 1.75rem; font-weight: 700; background: linear-gradient( 135deg, var(--apple-blue) 0%, var(--apple-purple) 100% ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin: 0 0 0.25rem 0; } .brand-subtitle { color: var(--text-secondary); font-size: 0.875rem; margin: 0; font-weight: 500; } .login-title-section { border-top: 1px solid var(--border-color); padding-top: 1.5rem; } .login-title { font-size: 1.5rem; font-weight: 600; color: var(--text-primary); margin: 0 0 0.5rem 0; } .login-subtitle { color: var(--text-secondary); margin: 0; font-size: 0.875rem; } .form-group { margin-bottom: 1.5rem; } .form-label { display: flex; align-items: center; font-size: 0.875rem; font-weight: 500; color: var(--text-primary); margin-bottom: 0.5rem; gap: 0.5rem; } .label-icon { width: 1rem; height: 1rem; fill: var(--text-secondary); } .form-input { width: 100%; padding: 0.875rem 1rem; border: 2px solid var(--border-color); border-radius: var(--radius-large); font-size: 0.875rem; transition: all 0.3s; box-sizing: border-box; background: var(--bg-primary); color: var(--text-primary); } .form-input:focus { outline: none; border-color: var(--apple-blue); box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1); background: var(--bg-primary); } .form-input.error { border-color: var(--apple-red); } .form-input:disabled { background-color: var(--bg-secondary); cursor: not-allowed; } .password-input-wrapper { position: relative; } .password-toggle { position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-secondary); padding: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-small); transition: all 0.2s; } .password-toggle:hover { color: var(--text-primary); background: var(--bg-hover); } .password-toggle:disabled { cursor: not-allowed; opacity: 0.5; } .icon { width: 1.25rem; height: 1.25rem; fill: currentColor; } .error-message { display: block; color: var(--apple-red); font-size: 0.75rem; margin-top: 0.25rem; font-weight: 500; } .form-options { display: flex; justify-content: flex-start; align-items: center; margin-bottom: 2rem; } .checkbox-wrapper { display: flex; align-items: center; cursor: pointer; } .checkbox { margin-right: 0.5rem; accent-color: var(--apple-blue); } .checkbox-label { font-size: 0.875rem; color: var(--text-primary); } .login-button { width: 100%; background: var(--apple-blue); color: white; border: none; border-radius: var(--radius-large); padding: 1rem; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; justify-content: center; gap: 0.5rem; box-shadow: var(--shadow-medium); min-height: 44px; } .login-button:hover:not(:disabled) { background: var(--apple-blue-dark); transform: translateY(-2px); box-shadow: var(--shadow-heavy); } .login-button:disabled { background: var(--system-gray-3); color: var(--text-secondary); cursor: not-allowed; transform: none; box-shadow: none; opacity: 0.6; } .loading-icon { width: 1rem; height: 1rem; animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .error-alert { display: flex; align-items: center; background: var(--bg-error); border: 1px solid var(--border-error); border-radius: var(--radius-large); padding: 0.875rem; margin-top: 1rem; color: var(--apple-red); font-size: 0.875rem; } .error-icon { width: 1.25rem; height: 1.25rem; margin-right: 0.5rem; flex-shrink: 0; fill: currentColor; } .error-close { margin-left: auto; background: none; border: none; cursor: pointer; color: var(--apple-red); padding: 0.25rem; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-small); transition: all 0.2s; } .error-close:hover { color: var(--apple-red); background: var(--bg-hover); } .error-close svg { width: 1rem; height: 1rem; fill: currentColor; } @media (max-width: 640px) { .login-container { padding: 0.5rem; } .login-card { padding: 2rem 1.5rem; } .brand-title { font-size: 1.5rem; } .login-title { font-size: 1.25rem; } .logo-icon { width: 60px; height: 60px; } } </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