feat: 前端认证
This commit is contained in:
@@ -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
17
web/src/api/auth.ts
Normal 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;
|
||||
};
|
@@ -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;
|
||||
|
@@ -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>
|
@@ -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')
|
||||
|
@@ -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,
|
||||
});
|
||||
|
||||
// 路由守卫
|
||||
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;
|
49
web/src/stores/authStore.ts
Normal file
49
web/src/stores/authStore.ts
Normal 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,
|
||||
};
|
||||
});
|
@@ -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
192
web/src/views/Login.vue
Normal 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>
|
@@ -13,5 +13,14 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
},
|
||||
base: './'
|
||||
base: './',
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
Reference in New Issue
Block a user