mirror of
https://github.com/EasyTierMC/ETMC.Web.git
synced 2025-12-07 21:15:48 +08:00
feat: 添加用户认证功能,包含登录、管理控制台和节点提交页面
This commit is contained in:
29
src/App.vue
29
src/App.vue
@@ -1,33 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { isAuthenticated } from '@/utils/request/api'
|
||||||
|
|
||||||
const year = new Date().getFullYear()
|
const year = new Date().getFullYear()
|
||||||
|
const isLoggedIn = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isLoggedIn.value = isAuthenticated()
|
||||||
|
|
||||||
|
// Re-check auth status on storage changes
|
||||||
|
window.addEventListener('storage', () => {
|
||||||
|
isLoggedIn.value = isAuthenticated()
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<header class="navbar bg-base-100 shadow-md">
|
<header class="navbar bg-base-100 shadow-md">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<div class="dropdown">
|
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52">
|
|
||||||
<li><RouterLink to="/">主页</RouterLink></li>
|
|
||||||
<li><RouterLink to="/monitor">节点监控</RouterLink></li>
|
|
||||||
<li><RouterLink to="/submit">提交节点</RouterLink></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h1 class="btn btn-ghost normal-case text-2xl font-bold">EasyTierMC Uptime</h1>
|
<h1 class="btn btn-ghost normal-case text-2xl font-bold">EasyTierMC Uptime</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-end hidden lg:flex">
|
<div class="navbar-end lg:flex">
|
||||||
<ul class="menu menu-horizontal px-1 gap-2">
|
<ul class="menu menu-horizontal px-1 gap-2">
|
||||||
<li><RouterLink to="/" class="btn btn-ghost">主页</RouterLink></li>
|
<li><RouterLink to="/" class="btn btn-ghost">主页</RouterLink></li>
|
||||||
<li><RouterLink to="/monitor" class="btn btn-ghost">节点监控</RouterLink></li>
|
<li><RouterLink to="/monitor" class="btn btn-ghost">节点监控</RouterLink></li>
|
||||||
<li><RouterLink to="/submit" class="btn btn-ghost">提交节点</RouterLink></li>
|
<li><RouterLink to="/submit" class="btn btn-ghost">提交节点</RouterLink></li>
|
||||||
|
<li v-if="isLoggedIn"><RouterLink to="/dashboard" class="btn btn-ghost">管理控制台</RouterLink></li>
|
||||||
|
<li v-else><RouterLink to="/login" class="btn btn-ghost">登录</RouterLink></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ const router = createRouter({
|
|||||||
name: 'submit',
|
name: 'submit',
|
||||||
component: () => import('../views/SubmitView.vue'),
|
component: () => import('../views/SubmitView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('../views/LoginView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('../views/DashboardView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
88
src/utils/request/api.ts
Normal file
88
src/utils/request/api.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import Api from './index'
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
status?: string
|
||||||
|
uptime?: number
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHealth() {
|
||||||
|
const path = import.meta.env.VITE_HEALTH_PATH || '/health'
|
||||||
|
return Api.get<HealthResponse>(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiDocsUrl() {
|
||||||
|
return import.meta.env.VITE_API_DOCS_URL || `${import.meta.env.VITE_API_BASE_URL}/api`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string) {
|
||||||
|
const res = (await Api.post<LoginResponse>('/auth/login', { username, password })) as unknown as LoginResponse
|
||||||
|
const token = (res as any)?.data.token
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('auth_token', token)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated() {
|
||||||
|
return !!localStorage.getItem('auth_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMe() {
|
||||||
|
return (Api.get('/auth/me') as Promise<any>)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin list (requires admin role)
|
||||||
|
export function listAdmins() {
|
||||||
|
return Api.get('/admins') as Promise<any[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAdmin(id: string | number) {
|
||||||
|
return Api.delete(`/admins/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiKey(data: any) {
|
||||||
|
return Api.post('/api-keys', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listApiKeys() {
|
||||||
|
return Api.get('/api-keys') as Promise<any[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleApiKeyStatus(id: string | number, status: string) {
|
||||||
|
return Api.patch(`/api-keys/${id}/status`, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteApiKey(id: string | number) {
|
||||||
|
return Api.delete(`/api-keys/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNode(data: any) {
|
||||||
|
return Api.post('/nodes', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNodes() {
|
||||||
|
return Api.get('/nodes') as Promise<any[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNode(id: string | number) {
|
||||||
|
return Api.delete(`/nodes/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNodeStatus(id: string | number, status: string) {
|
||||||
|
return Api.put(`/nodes/${id}/status`, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public peers (optional API key)
|
||||||
|
export function getPeers() {
|
||||||
|
return Api.get('/peers') as Promise<any[]>
|
||||||
|
}
|
||||||
433
src/views/DashboardView.vue
Normal file
433
src/views/DashboardView.vue
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
getMe,
|
||||||
|
listAdmins,
|
||||||
|
deleteAdmin,
|
||||||
|
listApiKeys,
|
||||||
|
createApiKey,
|
||||||
|
toggleApiKeyStatus,
|
||||||
|
deleteApiKey,
|
||||||
|
listNodes,
|
||||||
|
deleteNode,
|
||||||
|
updateNodeStatus,
|
||||||
|
logout
|
||||||
|
} from '@/utils/request/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// User info
|
||||||
|
const currentUser = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Active tab
|
||||||
|
const activeTab = ref<'nodes' | 'apiKeys' | 'users'>('nodes')
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
const nodeList = ref<any[]>([])
|
||||||
|
const loadingNodes = ref(false)
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
const apiKeys = ref<any[]>([])
|
||||||
|
const loadingApiKeys = ref(false)
|
||||||
|
const showApiKeyForm = ref(false)
|
||||||
|
const apiKeyForm = ref({ name: '', rateLimit: 100 })
|
||||||
|
|
||||||
|
// Admins
|
||||||
|
const userList = ref<any[]>([])
|
||||||
|
const loadingAdmins = ref(false)
|
||||||
|
|
||||||
|
const isAdmin = computed(() => currentUser.value?.username === 'admin')
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
const { data } = await getMe()
|
||||||
|
currentUser.value = data
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
|
error.value = '未授权,请先登录'
|
||||||
|
logout()
|
||||||
|
setTimeout(() => router.push('/login'), 1500)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes management
|
||||||
|
async function fetchNodes() {
|
||||||
|
loadingNodes.value = true
|
||||||
|
try {
|
||||||
|
const {data} = await listNodes()
|
||||||
|
nodeList.value = data
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('获取节点失败:', e)
|
||||||
|
} finally {
|
||||||
|
loadingNodes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteNode(id: string | number) {
|
||||||
|
if (!confirm('确定删除此节点?')) return
|
||||||
|
try {
|
||||||
|
await deleteNode(id)
|
||||||
|
await fetchNodes()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('删除失败: ' + (e.message || e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleNodeStatus(node: any) {
|
||||||
|
try {
|
||||||
|
const newStatus = node.status === 'active' ? 'inactive' : 'active'
|
||||||
|
await updateNodeStatus(node.id, newStatus)
|
||||||
|
await fetchNodes()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('更新状态失败: ' + (e.message || e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Keys management
|
||||||
|
async function fetchApiKeys() {
|
||||||
|
loadingApiKeys.value = true
|
||||||
|
try {
|
||||||
|
const {data} = await listApiKeys()
|
||||||
|
|
||||||
|
apiKeys.value = data
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('获取 API Keys 失败:', e)
|
||||||
|
} finally {
|
||||||
|
loadingApiKeys.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateApiKey() {
|
||||||
|
if (!apiKeyForm.value.name) return
|
||||||
|
try {
|
||||||
|
await createApiKey(apiKeyForm.value)
|
||||||
|
apiKeyForm.value = { name: '', rateLimit: 100 }
|
||||||
|
showApiKeyForm.value = false
|
||||||
|
await fetchApiKeys()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('创建 API Key 失败: ' + (e.message || e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleApiKeyStatus(key: any) {
|
||||||
|
try {
|
||||||
|
const newStatus = key.is_active === 'active' ? 'inactive' : 'active'
|
||||||
|
await toggleApiKeyStatus(key.id, newStatus)
|
||||||
|
await fetchApiKeys()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('更新状态失败: ' + (e.message || e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteApiKey(id: string | number) {
|
||||||
|
if (!confirm('确定删除此 API Key')) return
|
||||||
|
try {
|
||||||
|
await deleteApiKey(id)
|
||||||
|
await fetchApiKeys()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('删除失败: ' + (e.message || e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins management
|
||||||
|
async function fetchAdmins() {
|
||||||
|
if (!isAdmin.value) return
|
||||||
|
loadingAdmins.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await listAdmins()
|
||||||
|
|
||||||
|
userList.value = data
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('获取管理员列表失败:', e)
|
||||||
|
} finally {
|
||||||
|
loadingAdmins.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAdmin(id: string | number) {
|
||||||
|
if (!confirm('确定删除此管理员?')) return
|
||||||
|
try {
|
||||||
|
await deleteAdmin(id)
|
||||||
|
await fetchAdmins()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('删除失败: ' + (e.message || e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
function switchTab(tab: 'nodes' | 'apiKeys' | 'users') {
|
||||||
|
activeTab.value = tab
|
||||||
|
if (tab === 'nodes' && nodeList.value.length === 0) fetchNodes()
|
||||||
|
if (tab === 'apiKeys' && apiKeys.value.length === 0) fetchApiKeys()
|
||||||
|
if (tab === 'users' && userList.value.length === 0) fetchAdmins()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchUserInfo()
|
||||||
|
|
||||||
|
if (!error.value) await fetchNodes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div v-if="loading" class="text-center py-8">加载中...</div>
|
||||||
|
<div v-else-if="error" class="alert alert-error">{{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold">管理控制台</h1>
|
||||||
|
<p class="text-base-content/70 mt-1">
|
||||||
|
欢迎,{{ currentUser?.username }}
|
||||||
|
<span v-if="currentUser?.role" class="badge badge-sm ml-2">{{ currentUser.role }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="handleLogout">退出登录</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div role="tablist" class="tabs tabs-bordered mb-4">
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'nodes' }"
|
||||||
|
@click="switchTab('nodes')">
|
||||||
|
节点管理
|
||||||
|
</a>
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'apiKeys' }"
|
||||||
|
@click="switchTab('apiKeys')">
|
||||||
|
API Keys
|
||||||
|
</a>
|
||||||
|
<a v-if="isAdmin" role="tab" class="tab" :class="{ 'tab-active': activeTab === 'users' }"
|
||||||
|
@click="switchTab('users')">
|
||||||
|
管理员
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nodes Tab -->
|
||||||
|
<div v-show="activeTab === 'nodes'" class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-xl font-semibold">节点列表</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nodes Table -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>地址</th>
|
||||||
|
<th>协议</th>
|
||||||
|
<th>最大连接</th>
|
||||||
|
<th>中继</th>
|
||||||
|
<th>区域</th>
|
||||||
|
<th>运营商</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>响应时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="loadingNodes">
|
||||||
|
<td colspan="11" class="text-center">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="nodeList.length === 0">
|
||||||
|
<td colspan="11" class="text-center text-base-content/60">暂无节点</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else v-for="node in nodeList" :key="node.id">
|
||||||
|
<td>{{ node.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="font-medium">{{ node.name }}</div>
|
||||||
|
<div v-if="node.description" class="text-xs text-base-content/60">{{ node.description }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="font-mono text-sm">{{ node.host }}:{{ node.port }}</div>
|
||||||
|
<div v-if="node.network_name" class="text-xs text-base-content/60">网络: {{ node.network_name }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-sm">{{ node.protocol?.toUpperCase() }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ node.max_connections }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="node.allow_relay" class="badge badge-success badge-sm">允许</span>
|
||||||
|
<span v-else class="badge badge-ghost badge-sm">禁止</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ node.region || '-' }}</td>
|
||||||
|
<td>{{ node.ISP || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="{
|
||||||
|
'badge-success': node.status === 'Online',
|
||||||
|
'badge-error': node.status === 'Offline',
|
||||||
|
'badge-warning': node.status === 'Checking',
|
||||||
|
'badge-ghost': !node.status
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ node.status || 'Unknown' }}
|
||||||
|
</span>
|
||||||
|
<div v-if="node.last_status_update" class="text-xs text-base-content/60 mt-1">
|
||||||
|
{{ new Date(node.last_status_update).toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="node.response_time">{{ node.response_time }}ms</span>
|
||||||
|
<span v-else class="text-base-content/60">-</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
@click="handleToggleNodeStatus(node)"
|
||||||
|
:title="node.status === 'Online' ? '设为离线' : '设为在线'"
|
||||||
|
>
|
||||||
|
切换
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-error"
|
||||||
|
@click="handleDeleteNode(node.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="node.qq_number || node.mail" class="text-xs text-base-content/60 mt-1">
|
||||||
|
<div v-if="node.qq_number">QQ: {{ node.qq_number }}</div>
|
||||||
|
<div v-if="node.mail">{{ node.mail }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Keys Tab -->
|
||||||
|
<div v-show="activeTab === 'apiKeys'" class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-xl font-semibold">API Key 列表</h2>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="showApiKeyForm = !showApiKeyForm">
|
||||||
|
{{ showApiKeyForm ? '取消' : '+ 创建 API Key' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key Form -->
|
||||||
|
<div v-if="showApiKeyForm" class="card bg-base-200 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">创建新 API Key</h3>
|
||||||
|
<form @submit.prevent="handleCreateApiKey" class="grid gap-3">
|
||||||
|
<input v-model="apiKeyForm.name" type="text" placeholder="API Key 名称"
|
||||||
|
class="input input-bordered" required />
|
||||||
|
<input v-model.number="apiKeyForm.rateLimit" type="number" placeholder="速率限制(次/分钟)"
|
||||||
|
class="input input-bordered" min="1" />
|
||||||
|
<button type="submit" class="btn btn-primary">创建</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Keys Table -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>描述</th>
|
||||||
|
<th>速率限制</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="loadingApiKeys">
|
||||||
|
<td colspan="6" class="text-center">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="apiKeys.length === 0">
|
||||||
|
<td colspan="6" class="text-center text-base-content/60">暂无 API Key</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else v-for="key in apiKeys" :key="key.id">
|
||||||
|
<td>{{ key.id }}</td>
|
||||||
|
<td class="font-mono text-xs">{{ key.key }}</td>
|
||||||
|
<td>{{ key.description }}</td>
|
||||||
|
<td>{{ key.rate_limit}}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="key.status === 'active' ? 'badge-success' : 'badge-ghost'">
|
||||||
|
{{ key.is_active }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-xs btn-ghost" @click="handleToggleApiKeyStatus(key)" >
|
||||||
|
切换状态
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-xs btn-error ml-1" @click="handleDeleteApiKey(key.id)">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admins Tab -->
|
||||||
|
<div v-if="isAdmin" v-show="activeTab === 'users'" class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-xl font-semibold">管理员列表</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="loadingAdmins">
|
||||||
|
<td colspan="6" class="text-center">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="userList.length === 0">
|
||||||
|
<td colspan="6" class="text-center text-base-content/60">暂无管理员</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else v-for="user in userList" :key="user.id">
|
||||||
|
<td>{{ user.id }}</td>
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>{{ user.email }}</td>
|
||||||
|
<td>{{ new Date(user.created_at).toLocaleDateString()}}</td>
|
||||||
|
<td>{{ new Date(user.updated_at).toLocaleDateString()}}</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="user.id !== currentUser?.id" class="btn btn-xs btn-error" @click="handleDeleteAdmin(user.id)">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-xs text-base-content/60">当前用户</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tabs {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
70
src/views/LoginView.vue
Normal file
70
src/views/LoginView.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { login } from '@/utils/request/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const username = ref('admin')
|
||||||
|
const password = ref('admin123')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const success = ref(false)
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!username.value || !password.value) {
|
||||||
|
error.value = '请输入用户名和密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
success.value = false
|
||||||
|
try {
|
||||||
|
await login(username.value, password.value)
|
||||||
|
success.value = true
|
||||||
|
// 登录成功后跳转到管理控制台
|
||||||
|
setTimeout(() => router.push('/dashboard'), 600)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || '登录失败,请重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-200px)] flex items-center justify-center">
|
||||||
|
<div class="max-w-md w-full mx-auto px-4">
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl justify-center mb-4">登录</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="onSubmit" class="flex flex-col gap-4">
|
||||||
|
<label class="form-control w-full">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-medium">用户账户</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="username" type="text" class="input input-bordered w-full" autocomplete="username" />
|
||||||
|
</label>
|
||||||
|
<label class="form-control w-full">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-medium">登陆密码</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="password" type="password" class="input input-bordered w-full" autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary mt-2 w-full" type="submit" :disabled="loading">
|
||||||
|
<span>{{loading ? '登录中...' : '登录' }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,211 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex justify-center px-4">
|
||||||
<h2>提交节点</h2>
|
<div class="w-full max-w-3xl">
|
||||||
<p>这里是提交节点页面。</p>
|
<h2 class="text-3xl font-bold mb-2">提交节点</h2>
|
||||||
|
<p class="text-base-content/70 mb-6">填写以下信息提交新的 EasyTier 节点</p>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-xl mb-4">节点信息</h3>
|
||||||
|
<form @submit.prevent="handleCreateNode" class="grid gap-4">
|
||||||
|
|
||||||
|
<!-- 基础信息 -->
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">节点名称 <span class="text-error">*</span></span>
|
||||||
|
</div>
|
||||||
|
<input v-model="nodeForm.name" type="text" placeholder="例如:北京联通节点" class="input input-bordered" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">主机地址 <span class="text-error">*</span></span>
|
||||||
|
</div>
|
||||||
|
<input v-model="nodeForm.host" type="text" placeholder="例如:example.com 或 192.168.1.1" class="input input-bordered" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">端口 <span class="text-error">*</span></span>
|
||||||
|
</div>
|
||||||
|
<input v-model.number="nodeForm.port" type="number" placeholder="例如:11010" class="input input-bordered" min="1" max="65535" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">最大连接数 <span class="text-error">*</span></span>
|
||||||
|
</div>
|
||||||
|
<input v-model.number="nodeForm.max_connections" type="number" placeholder="例如:10" class="input input-bordered" min="1" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 协议选择 -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">协议 <span class="text-error">*</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-4 flex-wrap">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input v-model="nodeForm.protocol" type="radio" value="http" class="radio radio-primary" required />
|
||||||
|
<span>HTTP</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input v-model="nodeForm.protocol" type="radio" value="https" class="radio radio-primary" />
|
||||||
|
<span>HTTPS</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input v-model="nodeForm.protocol" type="radio" value="ws" class="radio radio-primary" />
|
||||||
|
<span>WS</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input v-model="nodeForm.protocol" type="radio" value="wss" class="radio radio-primary" />
|
||||||
|
<span>WSS</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 允许中继 -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
|
<input v-model="nodeForm.allow_relay" type="checkbox" class="checkbox checkbox-primary" />
|
||||||
|
<span class="label-text">允许中继连接</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 网络配置 -->
|
||||||
|
<div class="divider">网络配置</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">网络名称</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="nodeForm.network_name" type="text" placeholder="默认网络" class="input input-bordered" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">网络密钥</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="nodeForm.network_secret" type="password" placeholder="留空表示无密钥" class="input input-bordered" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 地理位置 -->
|
||||||
|
<div class="divider">地理位置</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">区域位置</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="nodeForm.region" type="text" placeholder="例如:华北、华东" class="input input-bordered" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">运营商</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="nodeForm.ISP" type="text" placeholder="例如:电信、联通、移动" class="input input-bordered" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 联系方式 -->
|
||||||
|
<div class="divider">联系方式</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">QQ 号码</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="nodeForm.qq_number" type="text" placeholder="例如:123456789" class="input input-bordered" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">邮箱地址</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="nodeForm.mail" type="email" placeholder="例如:admin@example.com" class="input input-bordered" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">节点描述</span>
|
||||||
|
</div>
|
||||||
|
<textarea v-model="nodeForm.description" placeholder="介绍一下这个节点..." class="textarea textarea-bordered h-24"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- 提交按钮 -->
|
||||||
|
<div class="card-actions justify-end mt-4">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="resetForm">重置</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span>{{ submitting ? '提交中...' : '提交节点' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 可根据需要添加逻辑
|
import { createNode } from '@/utils/request/api'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const nodeForm = ref({
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
port: 11010,
|
||||||
|
protocol: 'http',
|
||||||
|
allow_relay: true,
|
||||||
|
network_name: null as string | null,
|
||||||
|
network_secret: null as string | null,
|
||||||
|
max_connections: 10,
|
||||||
|
region: null as string | null,
|
||||||
|
ISP: null as string | null,
|
||||||
|
qq_number: null as string | null,
|
||||||
|
mail: null as string | null,
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
nodeForm.value = {
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
port: 11010,
|
||||||
|
protocol: 'http',
|
||||||
|
allow_relay: true,
|
||||||
|
network_name: null,
|
||||||
|
network_secret: null,
|
||||||
|
max_connections: 10,
|
||||||
|
region: null,
|
||||||
|
ISP: null,
|
||||||
|
qq_number: null,
|
||||||
|
mail: null,
|
||||||
|
description: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateNode() {
|
||||||
|
if (!nodeForm.value.name || !nodeForm.value.host) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await createNode(nodeForm.value)
|
||||||
|
alert('节点创建成功!')
|
||||||
|
resetForm()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('创建节点失败: ' + (e.message || e))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user