mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 10:14:35 +00:00
introduce uptime monitor for easytier public nodes (#1250)
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2557
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "easytier-uptime-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"easytier-uptime-frontend": "link:",
|
||||
"element-plus": "^2.8.8",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"unplugin-auto-import": "^0.18.6",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,326 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { healthApi } from './api'
|
||||
import {
|
||||
Monitor,
|
||||
Plus,
|
||||
CircleCheck,
|
||||
CircleClose,
|
||||
Loading,
|
||||
Link
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const healthStatus = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 安全地打开外部链接
|
||||
const openExternalLink = (url) => {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.open) {
|
||||
window.open(url, '_blank')
|
||||
} else {
|
||||
// 备用方案:创建一个临时链接元素
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open external link:', error)
|
||||
// 最后的备用方案:直接跳转
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查后端健康状态
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await healthApi.check()
|
||||
healthStatus.value = response.success
|
||||
} catch (error) {
|
||||
healthStatus.value = false
|
||||
console.error('Health check failed:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导航菜单项
|
||||
const menuItems = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
title: '节点监控',
|
||||
icon: 'Monitor'
|
||||
},
|
||||
{
|
||||
path: '/submit',
|
||||
name: 'submit',
|
||||
title: '提交节点',
|
||||
icon: 'Plus'
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
checkHealth()
|
||||
// 定期检查健康状态
|
||||
setInterval(checkHealth, 60000) // 每分钟检查一次
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- 顶部导航栏 -->
|
||||
<el-header class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="logo-section">
|
||||
<el-icon size="32" color="#409EFF">
|
||||
<Monitor />
|
||||
</el-icon>
|
||||
<h1 class="app-title">EasyTier Uptime</h1>
|
||||
</div>
|
||||
|
||||
<el-menu :default-active="route.name" mode="horizontal" class="nav-menu"
|
||||
@select="(key) => router.push(menuItems.find(item => item.name === key)?.path || '/')">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.name" :index="item.name">
|
||||
<el-icon>
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<div class="header-actions">
|
||||
<!-- 健康状态指示器 -->
|
||||
<el-tooltip :content="healthStatus === null ? '检查中...' : healthStatus ? '服务正常' : '服务异常'" placement="bottom">
|
||||
<div class="health-indicator">
|
||||
<el-icon :color="healthStatus === null ? '#909399' : healthStatus ? '#67C23A' : '#F56C6C'"
|
||||
:class="{ 'loading': loading }">
|
||||
<CircleCheck v-if="healthStatus === true" />
|
||||
<CircleClose v-else-if="healthStatus === false" />
|
||||
<Loading v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 管理员入口 -->
|
||||
<el-button type="warning" link @click="() => router.push('/admin/login')">
|
||||
管理员
|
||||
</el-button>
|
||||
|
||||
<!-- GitHub链接 -->
|
||||
<el-button type="primary" link @click="() => openExternalLink('https://github.com/EasyTier/EasyTier')">
|
||||
<el-icon>
|
||||
<Link />
|
||||
</el-icon>
|
||||
GitHub
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<el-main class="app-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<el-footer class="app-footer">
|
||||
<div class="footer-content">
|
||||
<p>
|
||||
© 2024 EasyTier Community |
|
||||
<el-button type="primary" link size="small"
|
||||
@click="() => openExternalLink('https://github.com/EasyTier/EasyTier')">
|
||||
开源项目
|
||||
</el-button>
|
||||
|
|
||||
<el-button type="primary" link size="small"
|
||||
@click="() => openExternalLink('https://github.com/EasyTier/EasyTier/blob/main/README.md')">
|
||||
使用文档
|
||||
</el-button>
|
||||
</p>
|
||||
</div>
|
||||
</el-footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 顶部导航栏 */
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
padding: 0;
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
background: transparent;
|
||||
border: none;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-menu .el-menu-item {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav-menu .el-menu-item:hover,
|
||||
.nav-menu .el-menu-item.is-active {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom-color: white;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.health-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.health-indicator .loading {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 主要内容区域 */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
/* 页面切换动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 底部信息 */
|
||||
.app-footer {
|
||||
background: white;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
text-align: center;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
.footer-content p {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Element Plus 组件样式覆盖 */
|
||||
.el-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-select {
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
// 只在管理员相关的API请求中添加token
|
||||
if (config.url && config.url.includes('/api/admin/')) {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 直接返回完整的response对象,让各个API方法自己处理数据格式
|
||||
return response
|
||||
},
|
||||
error => {
|
||||
console.error('API Error Details:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
config: {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
headers: error.config?.headers
|
||||
}
|
||||
})
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 节点相关API
|
||||
export const nodeApi = {
|
||||
// 获取节点列表
|
||||
async getNodes(params = {}) {
|
||||
const response = await api.get('/api/nodes', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 创建节点
|
||||
async createNode(data) {
|
||||
const response = await api.post('/api/nodes', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取单个节点
|
||||
async getNode(id) {
|
||||
const response = await api.get(`/api/nodes/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新节点
|
||||
async updateNode(id, data) {
|
||||
const response = await api.put(`/api/nodes/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 删除节点
|
||||
async deleteNode(id) {
|
||||
const response = await api.delete(`/api/nodes/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取节点健康记录
|
||||
async getNodeHealth(id, params = {}) {
|
||||
const response = await api.get(`/api/nodes/${id}/health`, { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取节点健康统计
|
||||
async getNodeHealthStats(id, params = {}) {
|
||||
const response = await api.get(`/api/nodes/${id}/health/stats`, { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 测试节点连接
|
||||
async testConnection(data) {
|
||||
const response = await api.post('/api/test_connection', data)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// 健康检查API
|
||||
export const healthApi = {
|
||||
async check() {
|
||||
const response = await api.get('/health')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员API
|
||||
export const adminApi = {
|
||||
// 管理员登录
|
||||
async login(password) {
|
||||
const response = await api.post('/api/admin/login', { password })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 验证token有效性
|
||||
async verifyToken() {
|
||||
const response = await api.get('/api/admin/verify')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取所有节点(包括未审批的)
|
||||
async getNodes(params = {}) {
|
||||
const response = await api.get('/api/admin/nodes', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 审批节点
|
||||
async approveNode(id) {
|
||||
const response = await api.put(`/api/admin/nodes/${id}/approve`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 撤销审批节点
|
||||
async revokeApproval(id) {
|
||||
const response = await api.put(`/api/admin/nodes/${id}/revoke`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 删除节点
|
||||
async deleteNode(id) {
|
||||
const response = await api.delete(`/api/admin/nodes/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新节点
|
||||
async updateNode(id, data) {
|
||||
const response = await api.put(`/api/admin/nodes/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div class="health-timeline" :class="{ 'compact': compact }">
|
||||
<div class="timeline-header">
|
||||
<span class="timeline-title">最近24小时健康状态</span>
|
||||
<div class="timeline-legend">
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot perfect"></span>
|
||||
<span class="legend-text">100%</span>
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot excellent"></span>
|
||||
<span class="legend-text">90-99%</span>
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot good"></span>
|
||||
<span class="legend-text">80-89%</span>
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot fair"></span>
|
||||
<span class="legend-text">60-79%</span>
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot poor"></span>
|
||||
<span class="legend-text">1-59%</span>
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot unknown"></span>
|
||||
<span class="legend-text">未知</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container" v-loading="loading">
|
||||
<div class="timeline-grid">
|
||||
<!-- 时间刻度 -->
|
||||
<div class="time-labels">
|
||||
<span v-for="(hour, idx) in timeLabels" :key="idx" class="time-label">
|
||||
{{ hour }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 健康状态条 -->
|
||||
<div class="health-bars">
|
||||
<div v-for="(segment, index) in healthSegments" :key="index" class="health-segment" :class="segment.status"
|
||||
:style="{ width: segment.width + '%', backgroundColor: segment.color }" :title="getSegmentTooltip(segment)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="health-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-value">{{ uptimePercentage }}%</span>
|
||||
<span class="summary-label">在线率</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-value">{{ avgResponseTime }}ms</span>
|
||||
<span class="summary-label">平均响应</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-value">{{ totalChecks }}</span>
|
||||
<span class="summary-label">检查次数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { nodeApi } from '../api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const props = defineProps({
|
||||
nodeInfo: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const avg_response_time = ref(0)
|
||||
|
||||
// 时间标签(24小时,每4小时一个标签)
|
||||
const timeLabels = computed(() => {
|
||||
const nodeInfo = props.nodeInfo
|
||||
const granularity = nodeInfo.ring_granularity
|
||||
const total_ring = nodeInfo.health_record_total_counter_ring
|
||||
const totalDuration = granularity * total_ring.length
|
||||
const now = dayjs(nodeInfo.last_check_time)
|
||||
const startTime = now.subtract(totalDuration, 'second')
|
||||
|
||||
const labelCount = 6
|
||||
const labelIntervalDuration = totalDuration / (labelCount - 1)
|
||||
|
||||
let labels = []
|
||||
for (let i = 0; i < labelCount; i++) {
|
||||
const time = startTime.add(i * labelIntervalDuration, 'second')
|
||||
labels.push(time.format('HH:mm'))
|
||||
}
|
||||
|
||||
return labels
|
||||
})
|
||||
|
||||
const total_checks = computed(() => {
|
||||
let total = 0
|
||||
for (let i = 0; i < props.nodeInfo.health_record_total_counter_ring.length; i++) {
|
||||
total += props.nodeInfo.health_record_total_counter_ring[i]
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
const healthy_checks = computed(() => {
|
||||
let total = 0
|
||||
for (let i = 0; i < props.nodeInfo.health_record_healthy_counter_ring.length; i++) {
|
||||
total += props.nodeInfo.health_record_healthy_counter_ring[i]
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
const uptime_percentage = computed(() => {
|
||||
return (healthy_checks.value / total_checks.value) * 100
|
||||
})
|
||||
|
||||
// 根据成功率获取颜色
|
||||
const getColorBySuccessRate = (rate) => {
|
||||
if (rate === 1) {
|
||||
return '#67c23a' // 100% 绿色
|
||||
} else if (rate >= 0.9) {
|
||||
return '#85ce61' // 90-99% 浅绿色
|
||||
} else if (rate >= 0.8) {
|
||||
return '#e6a23c' // 80-89% 橙色
|
||||
} else if (rate >= 0.6) {
|
||||
return '#f78989' // 60-79% 浅红色
|
||||
} else if (rate > 0) {
|
||||
return '#f56c6c' // 1-59% 红色
|
||||
} else {
|
||||
return '#c0c4cc' // 0% 或未知 灰色
|
||||
}
|
||||
}
|
||||
|
||||
// 健康状态分段
|
||||
const healthSegments = computed(() => {
|
||||
const nodeInfo = props.nodeInfo
|
||||
const total_ring = nodeInfo.health_record_total_counter_ring
|
||||
const healthy_ring = nodeInfo.health_record_healthy_counter_ring
|
||||
const granularity = nodeInfo.ring_granularity
|
||||
const totalDuration = granularity * total_ring.length
|
||||
|
||||
const segments = []
|
||||
const now = dayjs(nodeInfo.last_check_time)
|
||||
const startTime = now.subtract(totalDuration, 'second')
|
||||
|
||||
for (let i = total_ring.length - 1; i >= 0; i--) {
|
||||
const total_counter = total_ring[i]
|
||||
const healthy_counter = healthy_ring[i]
|
||||
const currentTime = startTime.subtract((i + 1) * granularity, 'second')
|
||||
const currentEndTime = currentTime.add(granularity, 'second')
|
||||
|
||||
let successRate = 0
|
||||
let currentStatus = 'unknown'
|
||||
|
||||
if (total_counter !== 0) {
|
||||
successRate = healthy_counter / total_counter
|
||||
if (successRate === 1) {
|
||||
currentStatus = 'perfect'
|
||||
} else if (successRate >= 0.9) {
|
||||
currentStatus = 'excellent'
|
||||
} else if (successRate >= 0.8) {
|
||||
currentStatus = 'good'
|
||||
} else if (successRate >= 0.6) {
|
||||
currentStatus = 'fair'
|
||||
} else if (successRate > 0) {
|
||||
currentStatus = 'poor'
|
||||
} else {
|
||||
currentStatus = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
segments.push({
|
||||
status: currentStatus,
|
||||
successRate: successRate,
|
||||
color: getColorBySuccessRate(successRate),
|
||||
width: (granularity / totalDuration) * 100,
|
||||
duration: granularity / 60.0,
|
||||
startTime: currentTime.format('HH:mm'),
|
||||
endTime: currentEndTime.format('HH:mm'),
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const uptimePercentage = computed(() => {
|
||||
return uptime_percentage.value.toFixed(1) || '0.0'
|
||||
})
|
||||
|
||||
const avgResponseTime = computed(() => {
|
||||
return (props.nodeInfo.last_response_time / 1000).toFixed(1) || '0.0'
|
||||
})
|
||||
|
||||
const totalChecks = computed(() => {
|
||||
return total_checks.value || 0
|
||||
})
|
||||
|
||||
// 获取分段提示信息
|
||||
const getSegmentTooltip = (segment) => {
|
||||
const statusText = {
|
||||
perfect: '完美',
|
||||
excellent: '优秀',
|
||||
good: '良好',
|
||||
fair: '一般',
|
||||
poor: '较差',
|
||||
failed: '失败',
|
||||
unknown: '未知'
|
||||
}[segment.status] || '未知'
|
||||
|
||||
const successRateText = segment.successRate > 0 ? `${(segment.successRate * 100).toFixed(1)}%` : '0%'
|
||||
|
||||
return `${segment.startTime} - ${segment.endTime}: ${statusText} (${successRateText}) - ${Math.round(segment.duration)}分钟`
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.health-timeline {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.timeline-legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-dot.perfect {
|
||||
background-color: #67c23a;
|
||||
}
|
||||
|
||||
.legend-dot.excellent {
|
||||
background-color: #85ce61;
|
||||
}
|
||||
|
||||
.legend-dot.good {
|
||||
background-color: #e6a23c;
|
||||
}
|
||||
|
||||
.legend-dot.fair {
|
||||
background-color: #f78989;
|
||||
}
|
||||
|
||||
.legend-dot.poor {
|
||||
background-color: #f56c6c;
|
||||
}
|
||||
|
||||
.legend-dot.unknown {
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.timeline-grid {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.time-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 10px;
|
||||
color: #c0c4cc;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.health-bars {
|
||||
display: flex;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background-color: #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.health-segment {
|
||||
height: 100%;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 颜色现在通过动态样式设置,不再需要这些CSS类 */
|
||||
|
||||
.health-segment:hover {
|
||||
opacity: 0.8;
|
||||
transform: scaleY(1.2);
|
||||
}
|
||||
|
||||
.response-time-chart {
|
||||
height: 30px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.response-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.health-summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 10px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 紧凑模式 */
|
||||
.health-timeline.compact {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.health-timeline.compact .timeline-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.health-timeline.compact .timeline-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.health-timeline.compact .health-bars {
|
||||
height: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.health-timeline.compact .health-summary {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.health-timeline.compact .summary-value {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.health-timeline.compact .summary-label {
|
||||
font-size: 9px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,507 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="left"
|
||||
@submit.prevent="handleSubmit">
|
||||
<el-form-item label="节点名称" prop="name" required>
|
||||
<el-input v-model="form.name" placeholder="请输入节点名称,如:北京-联通-01" maxlength="100" show-word-limit clearable>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Monitor />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="form-tip">建议使用地区-运营商-编号的格式命名</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="主机地址" prop="host" required>
|
||||
<el-input v-model="form.host" placeholder="请输入IP地址或域名" clearable>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Location />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="端口" prop="port" required>
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" placeholder="端口号" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="协议类型" prop="protocol" required>
|
||||
<el-radio-group v-model="form.protocol">
|
||||
<el-radio value="tcp">TCP</el-radio>
|
||||
<el-radio value="udp">UDP</el-radio>
|
||||
<el-radio value="ws">WebSocket</el-radio>
|
||||
<el-radio value="wss">WebSocket Secure</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="form-tip">选择节点支持的连接协议</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="允许中转" prop="allow_relay" required>
|
||||
<el-radio-group v-model="form.allow_relay">
|
||||
<el-radio :value="true">允许中转数据</el-radio>
|
||||
<el-radio :value="false">仅用于打洞</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="form-tip">选择节点是否允许中转其他用户的数据流量</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="网络名称" prop="network_name" required>
|
||||
<el-input v-model="form.network_name" placeholder="请输入EasyTier网络名称" maxlength="100" clearable>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Connection />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="form-tip">与 EasyTier 的 network name 一致,用于后端探活</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="网络密码" prop="network_secret" required>
|
||||
<el-input v-model="form.network_secret" type="password" placeholder="请输入网络密码" maxlength="100" clearable
|
||||
show-password>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Lock />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="form-tip">与 EasyTier 的 network secret 一致</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大网络数" prop="max_connections" required>
|
||||
<el-input-number v-model="form.max_connections" :min="1" :max="10000" placeholder="最大网络数量"
|
||||
style="width: 200px" />
|
||||
<div class="form-tip">节点能够承载的最大网络数量</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="节点描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="4" placeholder="请描述您的节点特点,如:地理位置、网络质量、使用限制等"
|
||||
maxlength="500" show-word-limit />
|
||||
<div class="form-tip">详细描述有助于用户选择合适的节点</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<el-form-item label="联系方式" prop="contact_info">
|
||||
<div class="contact-section">
|
||||
<el-form-item label="微信" prop="wechat">
|
||||
<el-input v-model="form.wechat" placeholder="请输入微信号" maxlength="50" clearable>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="QQ" prop="qq_number">
|
||||
<el-input v-model="form.qq_number" placeholder="请输入QQ号" maxlength="20" clearable>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<User />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="邮箱" prop="mail">
|
||||
<el-input v-model="form.mail" placeholder="请输入邮箱地址" maxlength="100" clearable>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Message />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-tip">请至少填写一种联系方式,便于节点问题时联系您(仅管理员可见)</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 连接测试 -->
|
||||
<el-form-item label="连接测试">
|
||||
<div class="test-section">
|
||||
<el-button type="warning" @click="testConnection" :loading="testing" :disabled="!canTest">
|
||||
<el-icon>
|
||||
<Connection />
|
||||
</el-icon>
|
||||
测试连接
|
||||
</el-button>
|
||||
<div v-if="testResult" class="test-result">
|
||||
<el-tag :type="testResult.success ? 'success' : 'danger'" size="large">
|
||||
{{ testResult.success ? '连接成功' : '连接失败' }}
|
||||
</el-tag>
|
||||
<span v-if="testResult.message" class="test-message">
|
||||
{{ testResult.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-tip">建议在提交前测试连接以确保节点可用</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 使用条款 -->
|
||||
<el-form-item prop="agreed" v-if="props.showAgreement">
|
||||
<el-checkbox v-model="form.agreed">
|
||||
我已阅读并同意
|
||||
<el-button type="primary" link @click="showTerms = true">
|
||||
《节点共享协议》
|
||||
</el-button>
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<el-form-item>
|
||||
<div class="submit-section">
|
||||
<el-button type="primary" size="large" @click="handleSubmit" :loading="submitting"
|
||||
:disabled="!form.agreed && props.showAgreement">
|
||||
<el-icon>
|
||||
<Upload />
|
||||
</el-icon>
|
||||
提交节点
|
||||
</el-button>
|
||||
<el-button size="large" @click="resetFields">
|
||||
<el-icon>
|
||||
<RefreshLeft />
|
||||
</el-icon>
|
||||
重置表单
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form> <!-- 使用条款对话框 -->
|
||||
|
||||
<el-dialog v-model="showTerms" title="节点共享协议" width="600px">
|
||||
<div class="terms-content">
|
||||
<h3>1. 节点共享原则</h3>
|
||||
<p>• 节点提供者应确保节点的稳定性和可用性</p>
|
||||
<p>• 不得利用共享节点进行违法违规活动</p>
|
||||
<p>• 尊重其他用户的使用权益</p>
|
||||
|
||||
<h3>2. 服务质量要求</h3>
|
||||
<p>• 节点应保持7x24小时稳定运行</p>
|
||||
<p>• 网络延迟应控制在合理范围内</p>
|
||||
<p>• 及时处理连接问题和故障</p>
|
||||
|
||||
<h3>3. 数据安全</h3>
|
||||
<p>• 不得记录或泄露用户传输数据</p>
|
||||
<p>• 保护用户隐私和数据安全</p>
|
||||
<p>• 遵守相关法律法规</p>
|
||||
|
||||
<h3>4. 免责声明</h3>
|
||||
<p>• 平台不对节点服务质量承担责任</p>
|
||||
<p>• 用户使用节点服务的风险自担</p>
|
||||
<p>• 平台有权移除不符合要求的节点</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showTerms = false">关闭</el-button>
|
||||
<el-button type="primary" @click="acceptTerms">同意并关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import {
|
||||
Monitor,
|
||||
Location,
|
||||
PriceTag,
|
||||
Connection,
|
||||
Upload,
|
||||
Edit,
|
||||
RefreshLeft,
|
||||
ChatDotRound,
|
||||
User,
|
||||
Message
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { nodeApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 11010,
|
||||
protocol: 'tcp',
|
||||
allow_relay: true,
|
||||
network_name: '',
|
||||
network_secret: '',
|
||||
max_connections: 100,
|
||||
description: '',
|
||||
wechat: '',
|
||||
qq_number: '',
|
||||
mail: '',
|
||||
agreed: false
|
||||
})
|
||||
},
|
||||
submitting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
submitText: {
|
||||
type: String,
|
||||
default: '提交节点'
|
||||
},
|
||||
submitIcon: {
|
||||
type: String,
|
||||
default: 'Upload'
|
||||
},
|
||||
showConnectionTest: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showAgreement: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showCancel: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'reset', 'cancel', 'show-terms'])
|
||||
|
||||
const formRef = ref()
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
const showTerms = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({ ...props.modelValue })
|
||||
|
||||
// 监听props变化,更新表单数据
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
Object.assign(form, newValue)
|
||||
}, { deep: true })
|
||||
|
||||
// 监听表单变化,向上传递
|
||||
watch(form, (newValue) => {
|
||||
emit('update:modelValue', { ...newValue })
|
||||
}, { deep: true })
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入节点名称', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '节点名称长度应在1-100个字符之间', trigger: 'blur' }
|
||||
],
|
||||
host: [
|
||||
{ required: true, message: '请输入主机地址', trigger: 'blur' },
|
||||
{ min: 1, max: 255, message: '主机地址长度应在1-255个字符之间', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/,
|
||||
message: '请输入有效的IP地址或域名',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
port: [
|
||||
{ required: true, message: '请输入端口号', trigger: 'blur' },
|
||||
{ type: 'number', min: 1, max: 65535, message: '端口号应在1-65535之间', trigger: 'blur' }
|
||||
],
|
||||
protocol: [
|
||||
{ required: true, message: '请选择协议类型', trigger: 'change' }
|
||||
],
|
||||
max_connections: [
|
||||
{ required: true, message: '请输入最大连接数', trigger: 'blur' },
|
||||
{ type: 'number', min: 1, max: 10000, message: '最大连接数应在1-10000之间', trigger: 'blur' }
|
||||
],
|
||||
version: [
|
||||
{ max: 50, message: '版本信息长度不能超过50个字符', trigger: 'blur' }
|
||||
],
|
||||
description: [
|
||||
{ max: 500, message: '描述长度不能超过500个字符', trigger: 'blur' }
|
||||
],
|
||||
wechat: [
|
||||
{ max: 50, message: '微信号长度不能超过50个字符', trigger: 'blur' }
|
||||
],
|
||||
qq_number: [
|
||||
{ max: 20, message: 'QQ号长度不能超过20个字符', trigger: 'blur' },
|
||||
{ pattern: /^[1-9][0-9]{4,19}$/, message: '请输入有效的QQ号', trigger: 'blur' }
|
||||
],
|
||||
mail: [
|
||||
{ max: 100, message: '邮箱地址长度不能超过100个字符', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
|
||||
],
|
||||
contact_info: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!form.wechat && !form.qq_number && !form.mail) {
|
||||
callback(new Error('请至少填写一种联系方式'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
agreed: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!value) {
|
||||
callback(new Error('请阅读并同意节点共享协议'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 是否可以测试连接
|
||||
const canTest = computed(() => {
|
||||
return form.host && form.port && form.protocol && form.network_name && form.network_secret
|
||||
})
|
||||
|
||||
const buildDataFromForm = () => {
|
||||
return {
|
||||
name: form.name || 'Test Node',
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
protocol: form.protocol,
|
||||
description: form.description || null,
|
||||
max_connections: form.max_connections || 100,
|
||||
allow_relay: form.allow_relay,
|
||||
network_name: form.network_name || null,
|
||||
network_secret: form.network_secret || null,
|
||||
wechat: form.wechat || null,
|
||||
qq_number: form.qq_number || null,
|
||||
mail: form.mail || null
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const testConnection = async () => {
|
||||
if (!canTest.value) {
|
||||
ElMessage.warning('请先填写主机地址、端口、协议、网络名称和网络密码')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
|
||||
try {
|
||||
// 构建测试数据
|
||||
const testData = buildDataFromForm()
|
||||
|
||||
// 调用实际的连接测试API
|
||||
const response = await nodeApi.testConnection(testData)
|
||||
|
||||
if (response.success) {
|
||||
testResult.value = {
|
||||
success: true,
|
||||
message: '连接测试成功,节点可正常访问'
|
||||
}
|
||||
ElMessage.success('连接测试成功')
|
||||
} else {
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: response.error || '连接测试失败'
|
||||
}
|
||||
ElMessage.error('连接测试失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('连接测试失败:', error)
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: error.response?.data?.error || '测试过程中发生错误,请检查网络连接'
|
||||
}
|
||||
ElMessage.error('连接测试失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
const submitData = buildDataFromForm()
|
||||
|
||||
emit('submit', submitData)
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetFields = () => {
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
testResult.value = null
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
const acceptTerms = () => {
|
||||
form.agreed = true
|
||||
showTerms.value = false
|
||||
ElMessage.success('已同意节点共享协议')
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
validate: () => formRef.value?.validate(),
|
||||
resetFields: () => formRef.value?.resetFields()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.test-message {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.submit-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contact-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contact-section .el-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.contact-section .el-form-item:last-of-type {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.contact-section .el-form-item__label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册Element Plus
|
||||
app.use(ElementPlus)
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
// 注册路由
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import NodeDashboard from '../views/NodeDashboard.vue'
|
||||
import SubmitNode from '../views/SubmitNode.vue'
|
||||
import AdminLogin from '../views/AdminLogin.vue'
|
||||
import AdminDashboard from '../views/AdminDashboard.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: NodeDashboard,
|
||||
meta: {
|
||||
title: '节点状态监控'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/submit',
|
||||
name: 'Submit',
|
||||
component: SubmitNode,
|
||||
meta: {
|
||||
title: '提交共享节点'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'AdminLogin',
|
||||
component: AdminLogin,
|
||||
meta: {
|
||||
title: '管理员登录'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'AdminDashboard',
|
||||
component: AdminDashboard,
|
||||
meta: {
|
||||
title: '管理员面板',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - EasyTier Uptime`
|
||||
}
|
||||
|
||||
// 检查管理员权限
|
||||
if (to.meta.requiresAuth) {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
next('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证token有效性
|
||||
try {
|
||||
const { adminApi } = await import('../api')
|
||||
await adminApi.verifyToken()
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error)
|
||||
localStorage.removeItem('admin_token')
|
||||
next('/admin/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,243 @@
|
||||
/* 自定义样式 */
|
||||
:root {
|
||||
--primary-color: #409EFF;
|
||||
--success-color: #67C23A;
|
||||
--warning-color: #E6A23C;
|
||||
--danger-color: #F56C6C;
|
||||
--info-color: #909399;
|
||||
|
||||
--text-primary: #303133;
|
||||
--text-regular: #606266;
|
||||
--text-secondary: #909399;
|
||||
--text-placeholder: #C0C4CC;
|
||||
|
||||
--border-base: #DCDFE6;
|
||||
--border-light: #E4E7ED;
|
||||
--border-lighter: #EBEEF5;
|
||||
--border-extra-light: #F2F6FC;
|
||||
|
||||
--background-base: #F5F7FA;
|
||||
--background-light: #FAFAFA;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 工具类 */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mb-10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mb-20 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mt-10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.p-10 {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.p-20 {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式断点 */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.desktop-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 状态指示器 */
|
||||
.status-online {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* 卡片阴影效果 */
|
||||
.card-shadow {
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.card-shadow:hover {
|
||||
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-overlay {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-overlay::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 表格样式增强 */
|
||||
.el-table .el-table__row:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 按钮组样式 */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-group .el-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
border-radius: 8px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 标签样式 */
|
||||
.tag-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 描述列表样式 */
|
||||
.description-list {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description-list .label {
|
||||
font-weight: 600;
|
||||
color: var(--text-regular);
|
||||
}
|
||||
|
||||
.description-list .value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-container class="admin-dashboard">
|
||||
<!-- 头部导航 -->
|
||||
<el-header class="admin-header">
|
||||
<div class="header-content">
|
||||
<div class="flex">
|
||||
<h1 class="header-title">管理员面板</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<router-link to="/" class="nav-link">
|
||||
返回首页
|
||||
</router-link>
|
||||
<el-button type="danger" @click="logout">
|
||||
退出登录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<el-main class="main-content">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20" class="mb-20">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon success">
|
||||
<el-icon>
|
||||
<Check />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">已审批节点</div>
|
||||
<div class="stat-value">{{ stats.approved }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon warning">
|
||||
<el-icon>
|
||||
<Clock />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">待审批节点</div>
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon info">
|
||||
<el-icon>
|
||||
<DataAnalysis />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总节点数</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon success">
|
||||
<el-icon>
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">在线节点</div>
|
||||
<div class="stat-value">{{ stats.active }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 筛选器 -->
|
||||
<el-card class="mb-20">
|
||||
<template #header>
|
||||
<span>筛选条件</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-form-item label="审批状态">
|
||||
<el-select v-model="filters.approved" @change="loadNodes" placeholder="全部" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="已审批" value="true" />
|
||||
<el-option label="待审批" value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-form-item label="在线状态">
|
||||
<el-select v-model="filters.active" @change="loadNodes" placeholder="全部" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="在线" value="true" />
|
||||
<el-option label="离线" value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-form-item label="协议">
|
||||
<el-select v-model="filters.protocol" @change="loadNodes" placeholder="全部" clearable>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="TCP" value="tcp" />
|
||||
<el-option label="UDP" value="udp" />
|
||||
<el-option label="WireGuard" value="wg" />
|
||||
<el-option label="WebSocket" value="ws" />
|
||||
<el-option label="WebSocket Secure" value="wss" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-form-item label="搜索">
|
||||
<el-input v-model="filters.search" @input="debounceSearch" placeholder="搜索节点名称或主机" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 节点列表 -->
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="flex-between">
|
||||
<div>
|
||||
<h3>节点列表</h3>
|
||||
<p class="text-secondary">管理所有共享节点</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="text-center p-20">
|
||||
<el-icon class="is-loading" size="32">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<p class="mt-10">加载中...</p>
|
||||
</div>
|
||||
|
||||
<el-table v-else-if="nodes.length > 0" :data="nodes" stripe>
|
||||
<el-table-column prop="name" label="节点名称" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center">
|
||||
<el-icon class="mr-2"
|
||||
:color="row.is_active && row.is_approved ? '#67C23A' : !row.is_approved ? '#E6A23C' : '#F56C6C'">
|
||||
<CircleCheck v-if="row.is_active && row.is_approved" />
|
||||
<Clock v-else-if="!row.is_approved" />
|
||||
<el-icon v-else>❌</el-icon>
|
||||
</el-icon>
|
||||
<span>{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="host" label="主机地址" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.host }}:{{ row.port }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="protocol" label="协议" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getProtocolType(row.protocol)" size="small">
|
||||
{{ row.protocol.toUpperCase() }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="is_approved" label="审批状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_approved ? 'success' : 'warning'" size="small">
|
||||
{{ row.is_approved ? '已审批' : '待审批' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="is_active" label="在线状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
|
||||
{{ row.is_active ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="editNode(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button v-if="!row.is_approved" type="success" size="small" @click="approveNode(row.id)">
|
||||
审批
|
||||
</el-button>
|
||||
<el-button v-if="row.is_approved" type="warning" size="small" @click="revokeApproval(row.id)">
|
||||
撤销
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="deleteNode(row.id)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-else description="暂无节点数据" />
|
||||
</el-card>
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- 编辑节点对话框 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑节点" width="800px" destroy-on-close>
|
||||
<NodeForm v-if="editDialogVisible" v-model="editForm" :submitting="updating" submit-text="更新节点" submit-icon="Edit"
|
||||
:show-connection-test="false" :show-agreement="false" :show-cancel="true" @submit="handleUpdateNode"
|
||||
@cancel="editDialogVisible = false" @reset="resetEditForm" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { adminApi } from '../api'
|
||||
import dayjs from 'dayjs'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Clock, DataAnalysis, CircleCheck, Loading } from '@element-plus/icons-vue'
|
||||
import NodeForm from '../components/NodeForm.vue'
|
||||
|
||||
export default {
|
||||
name: 'AdminDashboard',
|
||||
components: {
|
||||
Check,
|
||||
Clock,
|
||||
DataAnalysis,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
NodeForm
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
nodes: [],
|
||||
filters: {
|
||||
approved: '',
|
||||
active: '',
|
||||
protocol: '',
|
||||
search: ''
|
||||
},
|
||||
searchTimeout: null,
|
||||
editDialogVisible: false,
|
||||
editForm: {
|
||||
name: '',
|
||||
host: '',
|
||||
port: 11010,
|
||||
protocol: 'tcp',
|
||||
version: '',
|
||||
max_connections: 100,
|
||||
description: ''
|
||||
},
|
||||
editingNodeId: null,
|
||||
updating: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
stats() {
|
||||
const total = this.nodes.length
|
||||
const approved = this.nodes.filter(node => node.is_approved).length
|
||||
const pending = this.nodes.filter(node => !node.is_approved).length
|
||||
const active = this.nodes.filter(node => node.is_active).length
|
||||
|
||||
return {
|
||||
total,
|
||||
approved,
|
||||
pending,
|
||||
active
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
// 先验证token有效性
|
||||
try {
|
||||
await adminApi.verifyToken()
|
||||
await this.loadNodes()
|
||||
} catch (error) {
|
||||
console.error('Token verification failed in mounted:', error)
|
||||
this.logout()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadNodes() {
|
||||
try {
|
||||
this.loading = true
|
||||
const params = {}
|
||||
if (this.filters.approved !== '') {
|
||||
params.approved = this.filters.approved
|
||||
}
|
||||
if (this.filters.active !== '') {
|
||||
params.active = this.filters.active
|
||||
}
|
||||
if (this.filters.protocol) {
|
||||
params.protocol = this.filters.protocol
|
||||
}
|
||||
if (this.filters.search) {
|
||||
params.search = this.filters.search
|
||||
}
|
||||
|
||||
const response = await adminApi.getNodes(params)
|
||||
this.nodes = response.data?.items || []
|
||||
} catch (error) {
|
||||
console.error('加载节点失败:', error)
|
||||
if (error.response?.status === 401) {
|
||||
this.logout()
|
||||
} else {
|
||||
ElMessage.error('加载节点失败')
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async approveNode(nodeId) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要审批通过这个节点吗?', '确认审批', {
|
||||
type: 'warning'
|
||||
})
|
||||
await adminApi.approveNode(nodeId)
|
||||
ElMessage.success('审批成功')
|
||||
await this.loadNodes()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('审批失败:', error)
|
||||
ElMessage.error('审批失败')
|
||||
}
|
||||
}
|
||||
},
|
||||
async revokeApproval(nodeId) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要撤销这个节点的审批吗?撤销后节点将变为待审批状态。', '确认撤销审批', {
|
||||
type: 'warning'
|
||||
})
|
||||
await adminApi.revokeApproval(nodeId)
|
||||
ElMessage.success('撤销审批成功')
|
||||
await this.loadNodes()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('撤销审批失败:', error)
|
||||
ElMessage.error('撤销审批失败')
|
||||
}
|
||||
}
|
||||
},
|
||||
async deleteNode(nodeId) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个节点吗?此操作不可恢复!', '确认删除', {
|
||||
type: 'warning'
|
||||
})
|
||||
await adminApi.deleteNode(nodeId)
|
||||
ElMessage.success('删除成功')
|
||||
await this.loadNodes()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
},
|
||||
editNode(node) {
|
||||
this.editingNodeId = node.id
|
||||
this.editForm = node
|
||||
this.editDialogVisible = true
|
||||
},
|
||||
async handleUpdateNode(formData) {
|
||||
try {
|
||||
this.updating = true
|
||||
await adminApi.updateNode(this.editingNodeId, formData)
|
||||
ElMessage.success('节点更新成功')
|
||||
this.editDialogVisible = false
|
||||
await this.loadNodes()
|
||||
} catch (error) {
|
||||
console.error('更新节点失败:', error)
|
||||
ElMessage.error('更新节点失败')
|
||||
} finally {
|
||||
this.updating = false
|
||||
}
|
||||
},
|
||||
resetEditForm() {
|
||||
this.editForm = {
|
||||
name: '',
|
||||
host: '',
|
||||
port: 11010,
|
||||
protocol: 'tcp',
|
||||
version: '',
|
||||
max_connections: 100,
|
||||
description: ''
|
||||
}
|
||||
},
|
||||
debounceSearch() {
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout)
|
||||
}
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.loadNodes()
|
||||
}, 500)
|
||||
},
|
||||
formatDate(dateString) {
|
||||
return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss')
|
||||
},
|
||||
getProtocolType(protocol) {
|
||||
const typeMap = {
|
||||
tcp: 'primary',
|
||||
udp: 'success',
|
||||
wg: 'warning',
|
||||
ws: 'info',
|
||||
wss: 'danger'
|
||||
}
|
||||
return typeMap[protocol] || 'info'
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '确认退出', {
|
||||
type: 'warning'
|
||||
})
|
||||
localStorage.removeItem('admin_token')
|
||||
this.$router.push('/admin/login')
|
||||
} catch (error) {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-dashboard {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #409eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #66b1ff;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mb-20 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 28px;
|
||||
opacity: 0.3;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.stat-icon.success {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.stat-icon.warning {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.stat-icon.info {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mt-10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.p-20 {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-icon">
|
||||
<el-icon :size="48" color="#409EFF">
|
||||
<Lock />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h2 class="login-title">管理员登录</h2>
|
||||
<p class="login-subtitle">请输入管理员密码以访问管理面板</p>
|
||||
</div>
|
||||
|
||||
<div class="login-form">
|
||||
<el-form @submit.prevent="handleLogin" :model="form" :rules="rules" ref="loginForm">
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="请输入管理员密码" size="large" show-password
|
||||
:prefix-icon="Lock" @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="error">
|
||||
<el-alert :title="error" type="error" :closable="false" show-icon />
|
||||
</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>
|
||||
|
||||
<div class="login-divider">
|
||||
<el-divider>或</el-divider>
|
||||
</div>
|
||||
|
||||
<div class="login-actions">
|
||||
<el-button size="large" @click="$router.push('/')" class="back-button">
|
||||
<el-icon class="mr-2">
|
||||
<ArrowLeft />
|
||||
</el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { adminApi } from '../api'
|
||||
import { Lock, ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'AdminLogin',
|
||||
components: {
|
||||
Lock,
|
||||
ArrowLeft
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
error: '',
|
||||
form: {
|
||||
password: ''
|
||||
},
|
||||
rules: {
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 1, message: '密码不能为空', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async handleLogin() {
|
||||
if (!this.form.password) {
|
||||
this.error = '请输入密码'
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
|
||||
try {
|
||||
const response = await adminApi.login(this.form.password)
|
||||
|
||||
// 保存token
|
||||
const token = response.data?.token || response.token
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token)
|
||||
|
||||
// 跳转到管理面板
|
||||
this.$router.push('/admin')
|
||||
} else {
|
||||
throw new Error('No token received from server')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data
|
||||
})
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
this.error = '密码错误,请重新输入'
|
||||
} else if (error.response?.data?.message) {
|
||||
this.error = error.response.data.message
|
||||
} else if (error.message) {
|
||||
this.error = error.message
|
||||
} else {
|
||||
this.error = '登录失败,请检查网络连接'
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 如果已经登录,直接跳转到管理面板
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
this.$router.push('/admin')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 24px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.login-card {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Element Plus 组件样式覆盖 */
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:deep(.el-button) {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.el-button:hover) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,573 @@
|
||||
<template>
|
||||
<div class="node-dashboard">
|
||||
<!-- 页面头部 -->
|
||||
<div class="dashboard-header">
|
||||
<h1>EasyTier 节点状态监控</h1>
|
||||
<p class="subtitle">实时监控所有共享节点的健康状态和连接信息</p>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ totalNodes }}</div>
|
||||
<div class="stat-label">总节点数</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#409EFF">
|
||||
<Monitor />
|
||||
</el-icon>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ activeNodes }}</div>
|
||||
<div class="stat-label">在线节点</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#67C23A">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ averageLoad }} %</div>
|
||||
<div class="stat-label">平均负载</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#E6A23C">
|
||||
<Link />
|
||||
</el-icon>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ averageUptime }}%</div>
|
||||
<div class="stat-label">平均在线率</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#F56C6C">
|
||||
<TrendCharts />
|
||||
</el-icon>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<el-card class="filter-card">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable
|
||||
@input="handleSearch" />
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="statusFilter" placeholder="状态筛选" clearable @change="handleFilter">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="在线" value="true" />
|
||||
<el-option label="离线" value="false" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="protocolFilter" placeholder="协议筛选" clearable @change="handleFilter">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="TCP" value="tcp" />
|
||||
<el-option label="UDP" value="udp" />
|
||||
<el-option label="WS" value="ws" />
|
||||
<el-option label="WSS" value="wss" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button type="primary" @click="refreshData" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button type="success" @click="$router.push('/submit')">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
提交节点
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 节点列表 -->
|
||||
<el-card class="nodes-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>节点列表</span>
|
||||
<el-tag :type="loading ? 'info' : 'success'">
|
||||
{{ loading ? '加载中...' : `共 ${pagination.total} 个节点` }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
|
||||
<!-- 展开列 -->
|
||||
<el-table-column type="expand" width="50">
|
||||
<template #default="{ row }">
|
||||
<div class="expanded-content">
|
||||
<HealthTimeline :node-info="row" :compact="true" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="name" label="节点名称" width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="node-name">
|
||||
<el-icon :color="row.is_active ? '#67C23A' : '#F56C6C'">
|
||||
<CircleCheck v-if="row.is_active" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
<span>{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="address" label="节点连接地址" width="250">
|
||||
<template #header>
|
||||
<span>节点连接地址</span>
|
||||
<el-tooltip content="可以将节点链接填入命令行的 -p 参数,或者图形界面的节点地址字段(公共服务器或手动皆可)" placement="top" effect="light">
|
||||
<el-icon class="help-icon">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-tag type="primary" size="" style="margin-bottom: 0.2rem;"
|
||||
@click="copyAddress(apiUrl + 'node/' + row.id)"> {{
|
||||
apiUrl
|
||||
}}node/{{ row.id }}</el-tag>
|
||||
<el-tag type="info" size="" @click="copyAddress(row.address)">{{ row.address }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="版本" width="90">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; flex-direction: column; gap: 1px; align-items: flex-start;">
|
||||
<el-tag v-if="row.version" size="small" style="font-size: 11px; padding: 1px 4px;">{{ row.version
|
||||
}}</el-tag>
|
||||
<span v-else class="text-muted" style="font-size: 11px;">未知</span>
|
||||
<el-tag :type="row.allow_relay ? 'success' : 'info'" size="small"
|
||||
style="font-size: 9px; padding: 1px 3px;">
|
||||
{{ row.allow_relay ? '可中转' : '禁中转' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="连接状态" width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="connection-info">
|
||||
<span>{{ row.current_connections }}/{{ row.max_connections }}</span>
|
||||
<el-progress :percentage="row.usage_percentage" :color="getProgressColor(row.usage_percentage)"
|
||||
:stroke-width="6" :show-text="false" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="description" label="描述" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="description">{{ row.description || '暂无描述' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click.stop="viewNodeDetails(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.per_page"
|
||||
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 节点详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" :title="selectedNode?.name + ' - 详细信息'" width="800px" destroy-on-close>
|
||||
<div v-if="selectedNode" class="node-details">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="节点名称">{{ selectedNode.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="selectedNode.is_active ? 'success' : 'danger'">
|
||||
{{ selectedNode.is_active ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="主机地址">{{ selectedNode.host }}</el-descriptions-item>
|
||||
<el-descriptions-item label="端口">{{ selectedNode.port }}</el-descriptions-item>
|
||||
<el-descriptions-item label="协议">{{ selectedNode.protocol.toUpperCase() }}</el-descriptions-item>
|
||||
<el-descriptions-item label="版本">{{ selectedNode.version || '未知' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="允许中转">
|
||||
<el-tag :type="selectedNode.allow_relay ? 'success' : 'info'" size="small">
|
||||
{{ selectedNode.allow_relay ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ selectedNode.usage_percentage.toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(selectedNode.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDate(selectedNode.updated_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ selectedNode.description || '暂无描述' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 健康状态统计 -->
|
||||
<div class="health-stats" v-if="healthStats">
|
||||
<h3>健康状态统计 (最近24小时)</h3>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="health-stat-item">
|
||||
<div class="stat-value">{{ healthStats.uptime_percentage?.toFixed(1) || 0 }}%</div>
|
||||
<div class="stat-label">在线率</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="health-stat-item">
|
||||
<div class="stat-value">{{ (selectedNode.last_response_time / 1000) || 0 }}ms</div>
|
||||
<div class="stat-label">平均响应时间</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="health-stat-item">
|
||||
<div class="stat-value">{{ healthStats.total_checks || 0 }}</div>
|
||||
<div class="stat-label">检查次数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="health-stat-item">
|
||||
<div class="stat-value">{{ healthStats.failed_checks || 0 }}</div>
|
||||
<div class="stat-label">失败次数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { nodeApi } from '../api'
|
||||
import dayjs from 'dayjs'
|
||||
import HealthTimeline from '../components/HealthTimeline.vue'
|
||||
import {
|
||||
Monitor,
|
||||
CircleCheck,
|
||||
CircleClose,
|
||||
Link,
|
||||
TrendCharts,
|
||||
Search,
|
||||
Refresh,
|
||||
Plus
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const nodes = ref([])
|
||||
const searchText = ref('')
|
||||
const statusFilter = ref('true')
|
||||
const protocolFilter = ref('')
|
||||
const detailDialogVisible = ref(false)
|
||||
const selectedNode = ref(null)
|
||||
const healthStats = ref(null)
|
||||
const expandedRows = ref([])
|
||||
const apiUrl = ref(window.location.href)
|
||||
|
||||
// 分页数据
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const totalNodes = computed(() => nodes.value.length)
|
||||
const activeNodes = computed(() => nodes.value.filter(node => node.is_active).length)
|
||||
const averageLoad = computed(() =>
|
||||
(nodes.value.reduce((sum, node) => sum + node.current_connections, 0) / (nodes.value.length)).toFixed(2)
|
||||
)
|
||||
const averageUptime = computed(() => {
|
||||
if (nodes.value.length === 0) return 0
|
||||
const activeCount = nodes.value.filter(node => node.is_active).length
|
||||
return ((activeCount / nodes.value.length) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const fetchNodes = async (with_loading = true) => {
|
||||
try {
|
||||
if (with_loading) {
|
||||
loading.value = true
|
||||
}
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
per_page: pagination.per_page
|
||||
}
|
||||
|
||||
if (searchText.value) {
|
||||
params.search = searchText.value
|
||||
}
|
||||
if (statusFilter.value !== '') {
|
||||
params.is_active = statusFilter.value === 'true'
|
||||
}
|
||||
if (protocolFilter.value) {
|
||||
params.protocol = protocolFilter.value
|
||||
}
|
||||
|
||||
const response = await nodeApi.getNodes(params)
|
||||
if (response.success && response.data) {
|
||||
nodes.value = response.data.items
|
||||
pagination.total = response.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取节点列表失败:', error)
|
||||
ElMessage.error('获取节点列表失败')
|
||||
} finally {
|
||||
if (with_loading) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
fetchNodes()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchNodes()
|
||||
}
|
||||
|
||||
const handleFilter = () => {
|
||||
pagination.page = 1
|
||||
fetchNodes()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.per_page = size
|
||||
pagination.page = 1
|
||||
fetchNodes()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
pagination.page = page
|
||||
fetchNodes()
|
||||
}
|
||||
|
||||
const viewNodeDetails = async (node) => {
|
||||
selectedNode.value = node
|
||||
detailDialogVisible.value = true
|
||||
|
||||
// 获取健康状态统计
|
||||
try {
|
||||
const response = await nodeApi.getNodeHealthStats(node.id, { hours: 24 })
|
||||
if (response.success && response.data) {
|
||||
healthStats.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取健康状态统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const getProgressColor = (percentage) => {
|
||||
if (percentage < 50) return '#67C23A'
|
||||
if (percentage < 80) return '#E6A23C'
|
||||
return '#F56C6C'
|
||||
}
|
||||
|
||||
const copyAddress = (address) => {
|
||||
try {
|
||||
navigator.clipboard.writeText(address).then(() => {
|
||||
ElMessage.success(`地址已复制, ${address}`)
|
||||
}).catch(() => {
|
||||
ElMessage.error(`复制失败, ${address}`)
|
||||
})
|
||||
} catch (error) {
|
||||
ElMessage.error(`复制失败, ${address}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchNodes()
|
||||
|
||||
// 设置定时刷新
|
||||
setInterval(() => {
|
||||
fetchNodes(false)
|
||||
}, 3000) // 每30秒刷新一次
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-dashboard {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #606266;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 28px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nodes-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.address {
|
||||
margin-left: 8px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #C0C4CC;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.node-details {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.health-stats {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #EBEEF5;
|
||||
}
|
||||
|
||||
.health-stats h3 {
|
||||
margin-bottom: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.health-stat-item {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.health-stat-item .stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.health-stat-item .stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.expanded-content {
|
||||
padding: 16px 24px;
|
||||
background-color: #fafafa;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="submit-node">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<el-button type="primary" @click="$router.back()" class="back-btn">
|
||||
<el-icon>
|
||||
<ArrowLeft />
|
||||
</el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<h1>提交共享节点</h1>
|
||||
<p class="subtitle">分享您的EasyTier节点,为社区贡献力量</p>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" justify="center">
|
||||
<el-col :span="16">
|
||||
<!-- 提交表单 -->
|
||||
<el-card class="form-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
<span>节点信息</span>
|
||||
</div>
|
||||
</template>
|
||||
<NodeForm ref="formRef" @submit="handleSubmit" :submitting="submitting" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 侧边栏信息 -->
|
||||
<el-col :span="8">
|
||||
<el-card class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon>
|
||||
<InfoFilled />
|
||||
</el-icon>
|
||||
<span>提交须知</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="info-content">
|
||||
<div class="info-item">
|
||||
<el-icon color="#409EFF">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
<div>
|
||||
<h4>节点要求</h4>
|
||||
<p>确保您的节点稳定运行,具有良好的网络连接</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<el-icon color="#67C23A">
|
||||
<Lock />
|
||||
</el-icon>
|
||||
<div>
|
||||
<h4>隐私保护</h4>
|
||||
<p>关键信息仅社区管理员可见</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<el-icon color="#E6A23C">
|
||||
<Warning />
|
||||
</el-icon>
|
||||
<div>
|
||||
<h4>注意事项</h4>
|
||||
<p>请确保节点信息准确,避免提交虚假信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<el-icon color="#F56C6C">
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<div>
|
||||
<h4>移除条件</h4>
|
||||
<p>长期离线或不稳定的节点将被自动移除</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<el-icon color="#F56C6C">
|
||||
<DocumentChecked />
|
||||
</el-icon>
|
||||
<div>
|
||||
<h4>审核机制</h4>
|
||||
<p>所有节点提交均需要审核,审核通过后才会展示在节点列表中</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<el-card class="stats-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon>
|
||||
<DataAnalysis />
|
||||
</el-icon>
|
||||
<span>社区统计</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="stats-content">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ communityStats.totalNodes }}</div>
|
||||
<div class="stat-label">总节点数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ communityStats.activeNodes }}</div>
|
||||
<div class="stat-label">在线节点</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { nodeApi } from '../api'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
InfoFilled,
|
||||
CircleCheck,
|
||||
Lock,
|
||||
Warning,
|
||||
DocumentChecked,
|
||||
Delete,
|
||||
DataAnalysis
|
||||
} from '@element-plus/icons-vue'
|
||||
import NodeForm from '../components/NodeForm.vue'
|
||||
|
||||
const formRef = ref()
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
|
||||
// 社区统计数据
|
||||
const communityStats = reactive({
|
||||
totalNodes: 0,
|
||||
activeNodes: 0,
|
||||
})
|
||||
|
||||
const handleSubmit = async (submitData) => {
|
||||
try {
|
||||
const response = await nodeApi.createNode(submitData)
|
||||
|
||||
if (response.success) {
|
||||
ElMessage.success('节点提交成功!')
|
||||
ElMessageBox.confirm(
|
||||
'节点已成功提交,等待管理员审核后将会展示在节点列表中。如果信息填写错误请重新提交或者联系管理员更改。',
|
||||
'提交成功',
|
||||
{
|
||||
confirmButtonText: '查看列表',
|
||||
cancelButtonText: '继续提交',
|
||||
type: 'success'
|
||||
}
|
||||
).then(() => {
|
||||
router.push('/')
|
||||
}).catch(() => {
|
||||
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(response.error || '提交失败,请重试')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交节点失败:', error)
|
||||
ElMessage.error('提交失败,请检查网络连接')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCommunityStats = async () => {
|
||||
try {
|
||||
const response = await nodeApi.getNodes({ page: 1, per_page: 1 })
|
||||
if (response.success && response.data) {
|
||||
communityStats.totalNodes = response.data.total
|
||||
|
||||
// 获取活跃节点数
|
||||
const activeResponse = await nodeApi.getNodes({ page: 1, per_page: 1, is_active: true })
|
||||
if (activeResponse.success && activeResponse.data) {
|
||||
communityStats.activeNodes = activeResponse.data.total
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取社区统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchCommunityStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.submit-node {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #606266;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
.info-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-item h4 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.info-item p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stats-card :deep(.el-card__header) {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stats-card :deep(.card-header) {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stats-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.terms-content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.terms-content h3 {
|
||||
color: #303133;
|
||||
margin: 20px 0 10px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.terms-content h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.terms-content p {
|
||||
margin: 5px 0;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.submit-node {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
position: static;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.submit-section {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user