introduce uptime monitor for easytier public nodes (#1250)

This commit is contained in:
Sijie.Sun
2025-08-20 22:59:44 +08:00
committed by GitHub
parent 8f37d4ef7c
commit e6ec7f405c
61 changed files with 12122 additions and 17 deletions
@@ -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>