mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 18:24:36 +00:00
introduce uptime monitor for easytier public nodes (#1250)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user