This commit is contained in:
qaq
2025-06-17 12:17:33 +08:00
parent 4a6b2c8e0f
commit 9fa968d07c
648 changed files with 64968 additions and 1621 deletions
+857
View File
@@ -0,0 +1,857 @@
<template>
<div class="tunnel-container">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">
<i class="el-icon-connection"></i>
隧道管理
</h1>
<div class="header-actions">
<el-button
type="primary"
icon="el-icon-plus"
@click="handleAdd"
class="add-btn"
>
新增隧道
</el-button>
<el-button
type="success"
icon="el-icon-refresh"
@click="loadTunnels"
:loading="loading"
>
刷新
</el-button>
</div>
</div>
<!-- 隧道卡片展示 -->
<div class="cards-container" v-loading="loading">
<div class="cards-grid">
<div
v-for="tunnel in tunnelList"
:key="tunnel.id"
class="tunnel-card"
:class="{
'disabled': tunnel.status === 0
}"
>
<!-- 隧道头部 -->
<div class="tunnel-header">
<h3 class="tunnel-name">{{ tunnel.name }}</h3>
<div class="tunnel-badges">
<el-tag v-if="tunnel.type === 1" type="primary" size="mini">端口转发</el-tag>
<el-tag v-else type="warning" size="mini">隧道转发</el-tag>
<el-tag v-if="tunnel.flow === 1" type="success" size="mini">单向计算</el-tag>
<el-tag v-else type="info" size="mini">双向计算</el-tag>
</div>
</div>
<!-- 流程图 -->
<div class="tunnel-flow">
<!-- 客户端 -->
<div class="flow-item client">
<div class="flow-icon">
<i class="el-icon-monitor"></i>
</div>
<div class="flow-label">客户端</div>
</div>
<!-- 箭头 -->
<div class="flow-arrow">
<i class="el-icon-right"></i>
</div>
<!-- 入口 -->
<div class="flow-item entrance">
<div class="flow-icon">
<i class="el-icon-upload"></i>
</div>
<div class="flow-label">入口</div>
<div class="flow-detail">
<div class="detail-item">
<span class="label">IP:</span>
<span class="value">{{ tunnel.inIp }}</span>
</div>
<div class="detail-item">
<span class="label">端口:</span>
<span class="value">{{ tunnel.inPortSta === tunnel.inPortEnd ? tunnel.inPortSta : `${tunnel.inPortSta}-${tunnel.inPortEnd}` }}</span>
</div>
</div>
</div>
<!-- 箭头 -->
<div class="flow-arrow">
<i class="el-icon-right"></i>
</div>
<!-- 出口 -->
<div class="flow-item exit">
<div class="flow-icon">
<i class="el-icon-download"></i>
</div>
<div class="flow-label">出口</div>
<div class="flow-detail">
<div class="detail-item">
<span class="label">IP:</span>
<span class="value">{{ tunnel.outIp }}</span>
</div>
<div class="detail-item">
<span class="label">端口:</span>
<span class="value">{{ tunnel.outIpSta === tunnel.outIpEnd ? tunnel.outIpSta : `${tunnel.outIpSta}-${tunnel.outIpEnd}` }}</span>
</div>
</div>
</div>
<!-- 箭头 -->
<div class="flow-arrow">
<i class="el-icon-right"></i>
</div>
<!-- 目标 -->
<div class="flow-item target">
<div class="flow-icon">
<i class="el-icon-position"></i>
</div>
<div class="flow-label">目标</div>
<div class="flow-detail">
<div class="detail-item">
<span class="label">目标服务</span>
</div>
</div>
</div>
</div>
<!-- 创建时间 -->
<div class="tunnel-meta">
<div class="meta-item">
<i class="el-icon-time"></i>
<span>{{ tunnel.createdTime | dateFormat }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="tunnel-actions">
<el-button
size="small"
type="danger"
icon="el-icon-delete"
@click="handleDelete(tunnel)"
>
删除
</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && tunnelList.length === 0" class="empty-state">
<i class="el-icon-connection"></i>
<p>暂无隧道数据</p>
<el-button type="primary" @click="handleAdd">创建第一个隧道</el-button>
</div>
</div>
<!-- 新增/编辑隧道对话框 -->
<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="600px"
@close="resetForm"
>
<el-form
:model="tunnelForm"
:rules="rules"
ref="tunnelForm"
label-width="100px"
>
<el-form-item label="隧道名称" prop="name">
<el-input
v-model="tunnelForm.name"
placeholder="请输入隧道名称"
clearable
></el-input>
</el-form-item>
<el-form-item label="隧道类型" prop="type">
<el-radio-group v-model="tunnelForm.type" @change="handleTypeChange">
<el-radio :label="1">端口转发</el-radio>
<el-radio :label="2">隧道转发</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="流量计算" prop="flow">
<el-radio-group v-model="tunnelForm.flow">
<el-radio :label="1">单向计算(仅上传)</el-radio>
<el-radio :label="2">双向计算(上传+下载)</el-radio>
</el-radio-group>
<div class="form-hint">
单向:仅计算上传流量;双向:计算上传和下载的总流量
</div>
</el-form-item>
<el-divider content-position="left">入口配置</el-divider>
<el-form-item label="入口节点" prop="inNodeId">
<el-select
v-model="tunnelForm.inNodeId"
placeholder="请选择入口节点"
style="width: 100%"
@change="handleNodeChange"
>
<el-option
v-for="node in onlineNodes"
:key="node.id"
:label="`${node.name} (${node.status === 1 ? '在线' : '离线'})`"
:value="node.id"
:disabled="false"
>
<span style="float: left">{{ node.name }}</span>
<span style="float: right; font-size: 13px"
:style="{ color: node.status === 1 ? '#67c23a' : '#f56c6c' }">
{{ node.status === 1 ? '在线' : '离线' }}
</span>
</el-option>
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="起始端口" prop="inPortSta">
<el-input-number
v-model="tunnelForm.inPortSta"
:min="1"
:max="65535"
placeholder="起始端口"
style="width: 100%"
></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束端口" prop="inPortEnd">
<el-input-number
v-model="tunnelForm.inPortEnd"
:min="1"
:max="65535"
placeholder="结束端口"
style="width: 100%"
></el-input-number>
</el-form-item>
</el-col>
</el-row>
<!-- 只有隧道转发(type=2)时才显示出口配置 -->
<template v-if="tunnelForm.type === 2">
<el-divider content-position="left">出口配置</el-divider>
<el-form-item label="出口节点" prop="outNodeId">
<el-select
v-model="tunnelForm.outNodeId"
placeholder="请选择出口节点"
style="width: 100%"
@change="handleNodeChange"
>
<el-option
v-for="node in onlineNodes"
:key="node.id"
:label="`${node.name} (${node.status === 1 ? '在线' : '离线'})`"
:value="node.id"
:disabled="tunnelForm.inNodeId && node.id === tunnelForm.inNodeId"
>
<span style="float: left">{{ node.name }}</span>
<span style="float: right; font-size: 13px"
:style="{ color: node.status === 1 ? '#67c23a' : '#f56c6c' }">
{{ node.status === 1 ? '在线' : '离线' }}
{{ tunnelForm.inNodeId && node.id === tunnelForm.inNodeId ? ' (已选为入口)' : '' }}
</span>
</el-option>
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="起始端口" prop="outIpSta">
<el-input-number
v-model="tunnelForm.outIpSta"
:min="10000"
:max="65535"
placeholder="起始端口"
style="width: 100%"
></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束端口" prop="outIpEnd">
<el-input-number
v-model="tunnelForm.outIpEnd"
:min="10000"
:max="65535"
placeholder="结束端口"
style="width: 100%"
></el-input-number>
</el-form-item>
</el-col>
</el-row>
</template>
<el-alert
v-if="tunnelForm.type === 1"
title="端口转发模式:出口配置将自动使用入口配置"
type="info"
:closable="false"
show-icon
style="margin-top: 20px;">
</el-alert>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
{{ submitLoading ? '创建中...' : '创 建' }}
</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { createTunnel, deleteTunnel, getTunnelList, getNodeList } from "@/api";
export default {
name: "Tunnel",
data() {
return {
tunnelList: [],
nodeList: [],
loading: false,
dialogVisible: false,
dialogTitle: '新增隧道',
submitLoading: false,
tunnelForm: {
id: null,
name: '',
inNodeId: null,
inPortSta: 10000,
inPortEnd: 65535,
outNodeId: null,
outIpSta: 10000,
outIpEnd: 65535,
type: 1,
flow: 1, // 默认单向计算
status: 1 // 默认启用
},
rules: {
name: [
{ required: true, message: '请输入隧道名称', trigger: 'blur' },
{ min: 2, message: '隧道名称长度至少2位', trigger: 'blur' },
{ max: 50, message: '隧道名称长度不能超过50位', trigger: 'blur' }
],
inNodeId: [
{ required: true, message: '请选择入口节点', trigger: 'change' }
],
inPortSta: [
{ required: true, message: '请输入起始端口', trigger: 'blur' }
],
inPortEnd: [
{ required: true, message: '请输入结束端口', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择隧道类型', trigger: 'change' }
],
flow: [
{ required: true, message: '请选择流量计算类型', trigger: 'change' }
],
// 隧道转发时的出口验证规则
outNodeId: [
{ required: false, message: '请选择出口节点', trigger: 'change' }
],
outIpSta: [
{ required: false, message: '请输入起始端口', trigger: 'blur' }
],
outIpEnd: [
{ required: false, message: '请输入结束端口', trigger: 'blur' }
]
}
};
},
filters: {
dateFormat(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN');
}
},
computed: {
// 可用节点列表(显示所有节点,但在选择器中标识状态)
onlineNodes() {
return this.nodeList || [];
}
},
mounted() {
this.loadTunnels();
},
methods: {
// 加载节点列表
loadNodes() {
getNodeList().then(res => {
if (res.code === 0) {
this.nodeList = res.data || [];
} else {
this.$message.error(res.msg || '加载节点列表失败');
}
}).catch(error => {
this.$message.error('加载节点列表失败');
});
},
// 节点选择改变时的处理
handleNodeChange() {
// 可以在这里添加一些节点选择后的逻辑
// 例如:更新相关配置或验证
},
// 加载隧道列表
loadTunnels() {
this.loading = true;
getTunnelList().then(res => {
this.loading = false;
if (res.code === 0) {
this.tunnelList = res.data || [];
} else {
this.$message.error(res.msg || '加载隧道列表失败');
}
}).catch(error => {
this.loading = false;
this.$message.error('网络错误,请重试');
});
},
// 新增隧道
handleAdd() {
this.dialogVisible = true;
this.resetForm();
this.loadNodes(); // 打开对话框时加载节点列表
},
// 编辑功能已移除 - 隧道创建后不能修改
// 删除隧道
handleDelete(tunnel) {
this.$confirm(`确定要删除隧道 "${tunnel.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteTunnel(tunnel.id).then(res => {
if (res.code === 0) {
this.$message.success('删除成功');
this.loadTunnels();
} else {
this.$message.error(res.msg || '删除失败');
}
});
}).catch(() => {
// 用户取消删除
});
},
// 状态切换功能已移除 - 隧道创建后不能修改状态
// 隧道类型改变时的处理
handleTypeChange(value) {
if (value === 2) {
// 隧道转发时,出口字段变为必填
this.rules.outNodeId[0].required = true;
this.rules.outIpSta[0].required = true;
this.rules.outIpEnd[0].required = true;
} else {
// 端口转发时,出口字段不必填
this.rules.outNodeId[0].required = false;
this.rules.outIpSta[0].required = false;
this.rules.outIpEnd[0].required = false;
// 清空出口字段
this.tunnelForm.outNodeId = null;
this.tunnelForm.outIpSta = null;
this.tunnelForm.outIpEnd = null;
}
},
// 提交表单
handleSubmit() {
this.$refs.tunnelForm.validate(valid => {
if (valid) {
// 端口范围验证
if (this.tunnelForm.inPortSta > this.tunnelForm.inPortEnd) {
this.$message.error('入口起始端口不能大于结束端口');
return;
}
// 隧道转发时验证入口和出口节点不能相同
if (this.tunnelForm.type === 2 &&
this.tunnelForm.inNodeId === this.tunnelForm.outNodeId) {
this.$message.error('隧道转发模式下,入口和出口不能是同一个节点');
return;
}
if (this.tunnelForm.type === 2 &&
this.tunnelForm.outIpSta && this.tunnelForm.outIpEnd &&
this.tunnelForm.outIpSta > this.tunnelForm.outIpEnd) {
this.$message.error('出口起始端口不能大于结束端口');
return;
}
this.submitLoading = true;
// 只支持创建隧道,不支持编辑
const data = { ...this.tunnelForm };
createTunnel(data).then(res => {
this.submitLoading = false;
if (res.code === 0) {
this.$message.success('创建成功');
this.dialogVisible = false;
this.loadTunnels();
} else {
this.$message.error(res.msg || '创建失败');
}
}).catch(() => {
this.submitLoading = false;
this.$message.error('网络错误,请重试');
});
}
});
},
// 重置表单
resetForm() {
this.tunnelForm = {
id: null,
name: '',
inNodeId: null,
inPortSta: 10000,
inPortEnd: 65535,
outNodeId: null,
outIpSta: 10000,
outIpEnd: 65535,
type: 1,
flow: 1, // 默认单向计算
status: 1 // 默认启用
};
if (this.$refs.tunnelForm) {
this.$refs.tunnelForm.clearValidate();
}
}
}
};
</script>
<style scoped>
.tunnel-container {
padding: 20px;
background-color: #f5f7fa;
min-height: calc(100vh - 60px);
}
/* 页面头部 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
background: white;
padding: 20px 30px;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0;
display: flex;
align-items: center;
}
.page-title i {
margin-right: 8px;
color: #409eff;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
/* 卡片容器 */
.cards-container {
min-height: 400px;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
gap: 20px;
margin-bottom: 30px;
align-items: start;
}
/* 隧道卡片 */
.tunnel-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
border-left: 4px solid #409eff;
position: relative;
height: auto;
min-height: 320px;
display: flex;
flex-direction: column;
}
.tunnel-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
}
/* 禁用状态样式 */
.tunnel-card.disabled {
opacity: 0.6;
background: #f5f7fa !important;
border-left-color: #c0c4cc !important;
}
.tunnel-card.disabled .flow-icon {
background: #c0c4cc !important;
}
.tunnel-card.disabled .tunnel-name {
color: #909399 !important;
}
.tunnel-card.disabled .detail-item .value {
color: #c0c4cc !important;
}
.tunnel-card.disabled .flow-label {
color: #c0c4cc !important;
}
.tunnel-card.disabled::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(192, 196, 204, 0.1);
pointer-events: none;
border-radius: 12px;
}
/* 隧道头部 */
.tunnel-header {
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.tunnel-name {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0;
flex: 1;
}
.tunnel-badges {
display: flex;
gap: 8px;
align-items: center;
}
/* 流程图 */
.tunnel-flow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
overflow-x: auto;
}
.flow-item {
display: flex;
flex-direction: column;
align-items: center;
min-width: 100px;
flex-shrink: 0;
}
.flow-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
font-size: 18px;
color: white;
}
.client .flow-icon {
background: #909399;
}
.entrance .flow-icon {
background: #409eff;
}
.exit .flow-icon {
background: #f56c6c;
}
.target .flow-icon {
background: #67c23a;
}
.flow-label {
font-size: 14px;
font-weight: 500;
color: #606266;
margin-bottom: 8px;
}
.flow-detail {
text-align: center;
}
.detail-item {
font-size: 12px;
color: #909399;
margin-bottom: 2px;
}
.detail-item .label {
font-weight: 500;
}
.detail-item .value {
color: #303133;
font-family: monospace;
}
.flow-arrow {
color: #c0c4cc;
font-size: 16px;
margin: 0 10px;
}
/* 隧道元信息 */
.tunnel-meta {
display: flex;
align-items: center;
margin-bottom: 15px;
font-size: 12px;
color: #909399;
}
.meta-item {
display: flex;
align-items: center;
margin-right: 20px;
}
.meta-item i {
margin-right: 5px;
}
/* 操作按钮 */
.tunnel-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 15px;
margin-top: auto;
}
/* 表单提示 */
.form-hint {
color: #909399;
font-size: 12px;
margin-top: 5px;
line-height: 1.4;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #909399;
}
.empty-state i {
font-size: 64px;
margin-bottom: 20px;
opacity: 0.5;
}
.empty-state p {
font-size: 16px;
margin-bottom: 20px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.cards-grid {
grid-template-columns: 1fr;
gap: 15px;
}
.tunnel-flow {
flex-direction: column;
gap: 15px;
}
.flow-arrow {
transform: rotate(90deg);
}
.page-header {
flex-direction: column;
align-items: stretch;
gap: 15px;
}
.header-actions {
justify-content: center;
}
.tunnel-card {
padding: 15px;
}
.tunnel-name {
margin-right: 80px;
}
}
</style>