feat: 前端认证

This commit is contained in:
tbphp
2025-07-01 12:52:38 +08:00
parent 92fdd6d680
commit c447e3ad0b
10 changed files with 545 additions and 66 deletions

View File

@@ -1,7 +1,8 @@
<template>
<MainLayout />
<router-view />
</template>
<script setup lang="ts">
import MainLayout from './layouts/MainLayout.vue';
// App.vue 现在只需要渲染路由视图
// 路由器会决定是渲染 Login 组件还是 MainLayout 组件
</script>

17
web/src/api/auth.ts Normal file
View File

@@ -0,0 +1,17 @@
import apiClient from './index';
export interface LoginRequest {
auth_key: string;
}
export interface LoginResponse {
success: boolean;
message: string;
}
export const login = async (authKey: string): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/auth/login', {
auth_key: authKey
});
return response.data;
};

View File

@@ -1,4 +1,6 @@
import axios from "axios";
import { useAuthStore } from "@/stores/authStore";
import router from "@/router";
const apiClient = axios.create({
baseURL: "/api",
@@ -7,8 +9,42 @@ const apiClient = axios.create({
},
});
// 可以添加请求和响应拦截器
// apiClient.interceptors.request.use(...)
// apiClient.interceptors.response.use(...)
// 请求拦截器:自动添加认证头
apiClient.interceptors.request.use(
(config) => {
const authStore = useAuthStore();
const authKey = authStore.getAuthKey();
if (authKey) {
config.headers.Authorization = `Bearer ${authKey}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器处理401认证失败
apiClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response?.status === 401) {
// 认证失败,清除登录状态并跳转到登录页
const authStore = useAuthStore();
authStore.logout();
// 跳转到登录页(如果不在登录页的话)
if (router.currentRoute.value.path !== '/login') {
router.push('/login');
}
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -1,54 +1,179 @@
<template>
<el-container style="height: 100vh;">
<el-aside width="200px">
<el-menu
:default-active="activeIndex"
class="el-menu-vertical-demo"
@select="handleSelect"
router
>
<el-menu-item index="/dashboard">
<template #title>
<span>Dashboard</span>
</template>
</el-menu-item>
<el-menu-item index="/groups">
<template #title>
<span>Groups</span>
</template>
</el-menu-item>
<el-menu-item index="/logs">
<template #title>
<span>Logs</span>
</template>
</el-menu-item>
<el-menu-item index="/settings">
<template #title>
<span>Settings</span>
</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view></router-view>
</el-main>
<el-header class="header">
<div class="header-content">
<div class="header-title">
<h3>GPT Load 管理面板</h3>
</div>
<div class="header-actions" v-if="authStore.isAuthenticated">
<el-dropdown @command="handleCommand">
<span class="el-dropdown-link">
<el-icon><User /></el-icon>
<span style="margin-left: 5px;">管理员</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-header>
<el-container>
<el-aside width="200px">
<el-menu
:default-active="activeIndex"
class="el-menu-vertical-demo"
@select="handleSelect"
router
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<template #title>
<span>Dashboard</span>
</template>
</el-menu-item>
<el-menu-item index="/groups">
<el-icon><Files /></el-icon>
<template #title>
<span>Groups</span>
</template>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<template #title>
<span>Logs</span>
</template>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<template #title>
<span>Settings</span>
</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
User,
ArrowDown,
SwitchButton,
Odometer,
Files,
Document,
Setting
} from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/authStore'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const activeIndex = ref(route.path)
const handleSelect = (key: string, keyPath: string[]) => {
console.log(key, keyPath)
}
const handleCommand = async (command: string) => {
if (command === 'logout') {
try {
await ElMessageBox.confirm(
'确定要退出登录吗?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
authStore.logout()
ElMessage.success('已退出登录')
await router.push('/login')
} catch {
// 用户取消退出
}
}
}
</script>
<style scoped>
.el-menu-vertical-demo {
.header {
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
padding: 0;
line-height: 60px;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
padding: 0 20px;
}
.header-title h3 {
margin: 0;
color: #303133;
font-weight: 500;
}
.header-actions {
display: flex;
align-items: center;
}
.el-dropdown-link {
cursor: pointer;
color: #606266;
display: flex;
align-items: center;
padding: 0 12px;
height: 40px;
border-radius: 4px;
transition: background-color 0.3s;
}
.el-dropdown-link:hover {
background-color: #f5f7fa;
color: #409eff;
}
.el-menu-vertical-demo {
height: calc(100vh - 61px);
border-right: 1px solid #e4e7ed;
}
:deep(.el-menu-item) {
height: 50px;
line-height: 50px;
}
:deep(.el-menu-item.is-active) {
background-color: #ecf5ff;
color: #409eff;
}
:deep(.el-menu-item:hover) {
background-color: #f5f7fa;
}
</style>

View File

@@ -4,6 +4,7 @@ import App from './App.vue'
import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { useAuthStore } from './stores/authStore'
const app = createApp(App)
@@ -11,4 +12,8 @@ app.use(createPinia())
app.use(router)
app.use(ElementPlus)
// 确保认证状态在应用启动时初始化
const authStore = useAuthStore()
authStore.initializeAuth()
app.mount('#app')

View File

@@ -1,33 +1,50 @@
import { createRouter, createWebHistory } from 'vue-router';
import Dashboard from '../views/Dashboard.vue';
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "../stores/authStore";
import Dashboard from "../views/Dashboard.vue";
import Login from "../views/Login.vue";
import MainLayout from "../layouts/MainLayout.vue";
const routes = [
{
path: '/',
redirect: '/dashboard',
path: "/login",
name: "Login",
component: Login,
meta: { requiresAuth: false },
},
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard,
},
{
path: '/groups',
name: 'Groups',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/Groups.vue'),
},
{
path: '/logs',
name: 'Logs',
component: () => import('../views/Logs.vue'),
},
{
path: '/settings',
name: 'Settings',
component: () => import('../views/Settings.vue'),
path: "/",
component: MainLayout,
meta: { requiresAuth: true },
children: [
{
path: "",
redirect: "/dashboard",
},
{
path: "/dashboard",
name: "Dashboard",
component: Dashboard,
meta: { requiresAuth: true },
},
{
path: "/groups",
name: "Groups",
component: () => import("../views/Groups.vue"),
meta: { requiresAuth: true },
},
{
path: "/logs",
name: "Logs",
component: () => import("../views/Logs.vue"),
meta: { requiresAuth: true },
},
{
path: "/settings",
name: "Settings",
component: () => import("../views/Settings.vue"),
meta: { requiresAuth: true },
},
],
},
];
@@ -36,4 +53,27 @@ const router = createRouter({
routes,
});
export default router;
// 路由守卫
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore();
const isAuthenticated = authStore.isAuthenticated;
const requiresAuth = to.matched.some(
(record) => record.meta.requiresAuth !== false
);
if (requiresAuth && !isAuthenticated) {
// 需要认证但未登录,重定向到登录页
next({
name: "Login",
query: { redirect: to.fullPath },
});
} else if (to.name === "Login" && isAuthenticated) {
// 已登录用户访问登录页,重定向到仪表盘
next({ name: "Dashboard" });
} else {
// 正常访问
next();
}
});
export default router;

View File

@@ -0,0 +1,49 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
const AUTH_KEY_STORAGE = 'gpt-load-auth-key';
export const useAuthStore = defineStore('auth', () => {
// State
const authKey = ref<string>('');
// Computed
const isAuthenticated = computed(() => !!authKey.value);
// Actions
function login(key: string) {
authKey.value = key;
localStorage.setItem(AUTH_KEY_STORAGE, key);
}
function logout() {
authKey.value = '';
localStorage.removeItem(AUTH_KEY_STORAGE);
}
function getAuthKey(): string {
return authKey.value;
}
function initializeAuth() {
const storedKey = localStorage.getItem(AUTH_KEY_STORAGE);
if (storedKey) {
authKey.value = storedKey;
}
}
// 在store初始化时自动恢复认证状态
initializeAuth();
return {
// State
authKey,
// Computed
isAuthenticated,
// Actions
login,
logout,
getAuthKey,
initializeAuth,
};
});

View File

@@ -53,6 +53,11 @@ export interface Setting {
key: string;
value: string;
}
export interface AuthUser {
key: string;
isAuthenticated: boolean;
}
export interface Setting {
key: string;
value: string;

192
web/src/views/Login.vue Normal file
View File

@@ -0,0 +1,192 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<div class="login-header">
<h2>GPT Load - 登录</h2>
</div>
</template>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
@submit.prevent="handleLogin"
>
<el-form-item prop="authKey">
<el-input
v-model="loginForm.authKey"
type="password"
placeholder="请输入认证密钥"
size="large"
show-password
clearable
@keyup.enter="handleLogin"
>
<template #prefix>
<el-icon><Key /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
class="login-button"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
:closable="false"
class="error-alert"
/>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance } from 'element-plus'
import { Key } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/authStore'
import { login as loginAPI } from '../api/auth'
const router = useRouter()
const authStore = useAuthStore()
const loginFormRef = ref<FormInstance>()
const loading = ref(false)
const errorMessage = ref('')
const loginForm = reactive({
authKey: ''
})
const loginRules = {
authKey: [
{ required: true, message: '请输入认证密钥', trigger: 'blur' },
{ min: 1, message: '认证密钥不能为空', trigger: 'blur' }
]
}
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
const valid = await loginFormRef.value.validate()
if (!valid) return
loading.value = true
errorMessage.value = ''
// 调用后端API进行认证
const response = await loginAPI(loginForm.authKey)
if (response.success) {
// 认证成功,保存认证密钥
authStore.login(loginForm.authKey)
ElMessage.success('登录成功')
// 获取重定向路径,默认跳转到仪表盘
const redirect = router.currentRoute.value.query.redirect as string
await router.push(redirect || '/dashboard')
} else {
// 认证失败,显示错误信息
errorMessage.value = response.message || '认证失败,请检查认证密钥'
}
} catch (error: any) {
console.error('Login error:', error)
// 处理网络错误和API错误
if (error.response?.status === 401) {
errorMessage.value = '认证密钥错误,请重新输入'
} else if (error.response?.data?.message) {
errorMessage.value = error.response.data.message
} else if (error.message) {
errorMessage.value = `登录失败: ${error.message}`
} else {
errorMessage.value = '登录失败,请检查网络连接'
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 400px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
border-radius: 12px;
}
.login-header {
text-align: center;
}
.login-header h2 {
margin: 0;
color: #303133;
font-weight: 600;
}
.login-form {
padding: 0 20px;
}
.login-button {
width: 100%;
height: 45px;
font-size: 16px;
font-weight: 500;
}
.error-alert {
margin-top: 16px;
}
:deep(.el-card__header) {
padding: 20px 20px 10px 20px;
border-bottom: 1px solid #ebeef5;
}
:deep(.el-card__body) {
padding: 30px 20px 20px 20px;
}
:deep(.el-input__inner) {
height: 45px;
font-size: 14px;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-form-item:last-child) {
margin-bottom: 0;
}
</style>

View File

@@ -13,5 +13,14 @@ export default defineConfig({
build: {
outDir: 'dist'
},
base: './'
base: './',
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false
}
}
}
})