mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 02:06:38 +08:00
857 lines
22 KiB
Vue
857 lines
22 KiB
Vue
<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> |