mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-01 16:46:36 +08:00
init
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App',
|
||||
created() {
|
||||
// 设置移动端适配
|
||||
this.setRem();
|
||||
window.addEventListener('resize', this.setRem);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.setRem);
|
||||
},
|
||||
methods: {
|
||||
setRem() {
|
||||
const width = document.documentElement.clientWidth || document.body.clientWidth;
|
||||
// 移动端适配,基准值为375px宽度设备
|
||||
if (width < 768) {
|
||||
const ratio = width / 375;
|
||||
document.documentElement.style.fontSize = 14 * ratio + 'px';
|
||||
} else {
|
||||
document.documentElement.style.fontSize = '16px';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
height: 100%;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 移动端样式适配 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.el-table {
|
||||
width: 100% !important;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
padding: 8px 15px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
width: 95% !important;
|
||||
margin-top: 15vh !important;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
padding: 5px !important;
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
/* 确保文本可以换行,避免溢出 */
|
||||
.el-table .cell,
|
||||
.el-form-item__content,
|
||||
.el-input__inner {
|
||||
word-break: break-word !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
width: 90% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
import Network from './network';
|
||||
|
||||
export const login = (data) => Network.post("/user/login", data)
|
||||
|
||||
// 用户CRUD操作 - 全部使用POST请求
|
||||
export const createUser = (data) => Network.post("/user/create", data)
|
||||
export const getAllUsers = (pageData = {}) => Network.post("/user/list", pageData)
|
||||
export const updateUser = (data) => Network.post("/user/update", data)
|
||||
export const deleteUser = (id) => Network.post("/user/delete", { id })
|
||||
export const getUserPackageInfo = () => Network.post("/user/package")
|
||||
|
||||
// 节点CRUD操作 - 全部使用POST请求
|
||||
export const createNode = (data) => Network.post("/node/create", data)
|
||||
export const getNodeList = () => Network.post("/node/list")
|
||||
export const updateNode = (data) => Network.post("/node/update", data)
|
||||
export const deleteNode = (id) => Network.post("/node/delete", { id })
|
||||
export const getNodeInstallCommand = (id) => Network.post("/node/install", { id })
|
||||
|
||||
// 隧道CRUD操作 - 全部使用POST请求
|
||||
export const createTunnel = (data) => Network.post("/tunnel/create", data)
|
||||
export const getTunnelList = () => Network.post("/tunnel/list")
|
||||
export const getTunnelById = (id) => Network.post("/tunnel/get", { id })
|
||||
export const deleteTunnel = (id) => Network.post("/tunnel/delete", { id })
|
||||
|
||||
// 用户隧道权限管理操作 - 全部使用POST请求
|
||||
export const assignUserTunnel = (data) => Network.post("/tunnel/user/assign", data)
|
||||
export const getUserTunnelList = (queryData = {}) => Network.post("/tunnel/user/list", queryData)
|
||||
export const removeUserTunnel = (params) => Network.post("/tunnel/user/remove", params)
|
||||
export const updateUserTunnelFlow = (params) => Network.post("/tunnel/user/updateFlow", params)
|
||||
export const updateUserTunnel = (data) => Network.post("/tunnel/user/update", data)
|
||||
export const userTunnel = () => Network.post("/tunnel/user/tunnel")
|
||||
|
||||
// 转发CRUD操作 - 全部使用POST请求
|
||||
export const createForward = (data) => Network.post("/forward/create", data)
|
||||
export const getForwardList = () => Network.post("/forward/list")
|
||||
export const updateForward = (data) => Network.post("/forward/update", data)
|
||||
export const deleteForward = (id) => Network.post("/forward/delete", { id })
|
||||
|
||||
// 转发服务控制操作 - 通过Java后端接口
|
||||
export const pauseForwardService = (forwardId) => Network.post("/forward/pause", { id: forwardId })
|
||||
export const resumeForwardService = (forwardId) => Network.post("/forward/resume", { id: forwardId })
|
||||
|
||||
// 限速规则CRUD操作 - 全部使用POST请求
|
||||
export const createSpeedLimit = (data) => Network.post("/speed-limit/create", data)
|
||||
export const getSpeedLimitList = () => Network.post("/speed-limit/list")
|
||||
export const updateSpeedLimit = (data) => Network.post("/speed-limit/update", data)
|
||||
export const deleteSpeedLimit = (id) => Network.post("/speed-limit/delete", { id })
|
||||
|
||||
// 修改密码接口
|
||||
export const updatePassword = (data) => Network.post("/user/updatePassword", data)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
get: function(path = '', data = {}) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
axios.get(path, {
|
||||
params: data,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
"Authorization": window.localStorage.getItem('token')
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('GET请求错误:', error);
|
||||
resolve({"code": -1, "msg": error.message || "网络请求失败"});
|
||||
});
|
||||
});
|
||||
},
|
||||
post: function(path = '', data = {}) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
axios.post(path, data, {
|
||||
timeout: 60000,
|
||||
headers:{
|
||||
"Authorization": window.localStorage.getItem('token'),
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('POST请求错误:', error);
|
||||
resolve({"code": -1, "msg": error.message || "网络请求失败"});
|
||||
});
|
||||
});
|
||||
},
|
||||
put: function(path = '', data = {}) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
axios.put(path, data, {
|
||||
timeout: 60000,
|
||||
headers:{
|
||||
"Authorization": window.localStorage.getItem('token'),
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('PUT请求错误:', error);
|
||||
resolve({"code": -1, "msg": error.message || "网络请求失败"});
|
||||
});
|
||||
});
|
||||
},
|
||||
delete: function(path = '') {
|
||||
return new Promise(function(resolve, reject) {
|
||||
axios.delete(path, {
|
||||
timeout: 60000,
|
||||
headers:{
|
||||
"Authorization": window.localStorage.getItem('token')
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('DELETE请求错误:', error);
|
||||
resolve({"code": -1, "msg": error.message || "网络请求失败"});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,13 @@
|
||||
// 环境变量配置
|
||||
const config = {
|
||||
// 优先使用运行时配置,然后是环境变量,最后是默认值
|
||||
API_BASE_URL: window.APP_CONFIG?.VUE_APP_API_URL || process.env.VUE_APP_API_BASE_URL || "http://127.0.0.1:6365/api/v1",
|
||||
|
||||
// WebSocket地址配置
|
||||
WS_BASE_URL: window.APP_CONFIG?.VUE_APP_WS_URL || process.env.VUE_APP_WS_BASE_URL || "ws://127.0.0.1:6365",
|
||||
|
||||
// 其他配置
|
||||
REQUEST_TIMEOUT: process.env.VUE_APP_REQUEST_TIMEOUT || 60000
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,71 @@
|
||||
import { isAdmin, hasRole, isLoggedIn } from '@/utils/auth'
|
||||
|
||||
/**
|
||||
* 权限指令
|
||||
* 使用方法:
|
||||
* v-admin - 只有管理员可见
|
||||
* v-role="0" - 只有指定角色可见
|
||||
* v-auth - 只有已登录用户可见
|
||||
*/
|
||||
|
||||
// 管理员权限指令
|
||||
export const adminDirective = {
|
||||
inserted(el, binding) {
|
||||
if (!isAdmin()) {
|
||||
// 移除元素
|
||||
el.parentNode && el.parentNode.removeChild(el)
|
||||
}
|
||||
},
|
||||
|
||||
update(el, binding) {
|
||||
// 动态更新权限状态
|
||||
if (!isAdmin()) {
|
||||
el.style.display = 'none'
|
||||
} else {
|
||||
el.style.display = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 角色权限指令
|
||||
export const roleDirective = {
|
||||
inserted(el, binding) {
|
||||
const targetRole = binding.value
|
||||
if (!hasRole(targetRole)) {
|
||||
el.parentNode && el.parentNode.removeChild(el)
|
||||
}
|
||||
},
|
||||
|
||||
update(el, binding) {
|
||||
const targetRole = binding.value
|
||||
if (!hasRole(targetRole)) {
|
||||
el.style.display = 'none'
|
||||
} else {
|
||||
el.style.display = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 登录权限指令
|
||||
export const authDirective = {
|
||||
inserted(el, binding) {
|
||||
if (!isLoggedIn()) {
|
||||
el.parentNode && el.parentNode.removeChild(el)
|
||||
}
|
||||
},
|
||||
|
||||
update(el, binding) {
|
||||
if (!isLoggedIn()) {
|
||||
el.style.display = 'none'
|
||||
} else {
|
||||
el.style.display = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量注册指令的函数
|
||||
export function installPermissionDirectives(Vue) {
|
||||
Vue.directive('admin', adminDirective)
|
||||
Vue.directive('role', roleDirective)
|
||||
Vue.directive('auth', authDirective)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import axios from 'axios'
|
||||
import ElementUI from 'element-ui'
|
||||
import 'element-ui/lib/theme-chalk/index.css'
|
||||
import { installPermissionDirectives } from '@/directives/permission'
|
||||
import { isAdmin, hasRole, isLoggedIn, getCurrentUserRoleId } from '@/utils/auth'
|
||||
import config from '@/config/env'
|
||||
|
||||
|
||||
// 注册Element UI
|
||||
Vue.use(ElementUI)
|
||||
|
||||
// 注册权限指令
|
||||
installPermissionDirectives(Vue)
|
||||
|
||||
// 添加全局权限方法
|
||||
Vue.prototype.$isAdmin = isAdmin
|
||||
Vue.prototype.$hasRole = hasRole
|
||||
Vue.prototype.$isLoggedIn = isLoggedIn
|
||||
Vue.prototype.$getCurrentUserRoleId = getCurrentUserRoleId
|
||||
|
||||
// 注册axios
|
||||
const base_url = config.API_BASE_URL
|
||||
Vue.prototype.baseUrl = base_url
|
||||
Vue.prototype.$axios = axios
|
||||
axios.defaults.baseURL = base_url
|
||||
Vue.config.productionTip = false
|
||||
|
||||
|
||||
Vue.filter('dateFormat',function(originVal){
|
||||
const dt = new Date(originVal)
|
||||
const y = dt.getFullYear()
|
||||
const m = (dt.getMonth() + 1 + '').padStart(2,'0')
|
||||
const d = (dt.getDate() + '').padStart(2,'0')
|
||||
const hh = (dt.getHours()+'').padStart(2,'0')
|
||||
const mm = (dt.getMinutes()+'').padStart(2,'0')
|
||||
const ss = (dt.getSeconds()+'').padStart(2,'0')
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
|
||||
})
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
render: h => h(App),
|
||||
}).$mount('#app')
|
||||
@@ -0,0 +1,49 @@
|
||||
import Vue from 'vue';
|
||||
import VueRouter from 'vue-router';
|
||||
|
||||
// 路由懒加载
|
||||
|
||||
import Home from "@/views/Home.vue";
|
||||
import Login from "@/views/Login.vue";
|
||||
import Index from "@/views/Index";
|
||||
import User from "@/views/User";
|
||||
import Node from "@/views/node";
|
||||
import Tunnel from "@/views/Tunnel";
|
||||
import Forward from "@/views/Forward";
|
||||
import Limit from "@/views/Limit";
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
// 路由配置
|
||||
|
||||
const routes = [
|
||||
{path: '/', redirect: '/login'},
|
||||
{path: '/login', component: Login},
|
||||
{
|
||||
path: '/home', component: Home, redirect: '/index', children: [
|
||||
{path: '/index', component: Index},
|
||||
{path: '/user', component: User},
|
||||
{path: '/node', component: Node},
|
||||
{path: '/tunnel', component: Tunnel},
|
||||
{path: '/forward', component: Forward},
|
||||
{path: '/limit', component: Limit}
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
const router = new VueRouter({
|
||||
base: process.env.BASE_URL,
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.path === '/login') return next();
|
||||
const tokenStr = localStorage.getItem('token')
|
||||
if (!tokenStr) {
|
||||
return next('/login')
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,70 @@
|
||||
import { getRoleIdFromToken, isTokenValid } from './jwt'
|
||||
|
||||
/**
|
||||
* 权限工具类
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前用户的token
|
||||
* @returns {string|null} token
|
||||
*/
|
||||
export function getToken() {
|
||||
return localStorage.getItem('token')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的角色ID
|
||||
* @returns {number|null} 角色ID
|
||||
*/
|
||||
export function getCurrentUserRoleId() {
|
||||
const token = getToken()
|
||||
if (!token || !isTokenValid(token)) {
|
||||
return null
|
||||
}
|
||||
return getRoleIdFromToken(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否是管理员
|
||||
* @returns {boolean} 是否是管理员
|
||||
*/
|
||||
export function isAdmin() {
|
||||
const roleId = getCurrentUserRoleId()
|
||||
return roleId === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否有指定角色
|
||||
* @param {number} targetRoleId 目标角色ID
|
||||
* @returns {boolean} 是否有指定角色
|
||||
*/
|
||||
export function hasRole(targetRoleId) {
|
||||
const roleId = getCurrentUserRoleId()
|
||||
return roleId === targetRoleId
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否已登录且token有效
|
||||
* @returns {boolean} 是否已登录
|
||||
*/
|
||||
export function isLoggedIn() {
|
||||
const token = getToken()
|
||||
return token && isTokenValid(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查装饰器函数
|
||||
* @param {Function} fn 要执行的函数
|
||||
* @param {string} errorMsg 权限不足时的错误提示
|
||||
* @returns {Function} 包装后的函数
|
||||
*/
|
||||
export function requireAdmin(fn, errorMsg = '权限不足,仅管理员可操作') {
|
||||
return function(...args) {
|
||||
if (!isAdmin()) {
|
||||
// 可以在这里集成消息提示组件
|
||||
console.warn(errorMsg)
|
||||
return false
|
||||
}
|
||||
return fn.apply(this, args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* JWT工具类 - 前端版本
|
||||
*/
|
||||
|
||||
/**
|
||||
* 从JWT Token中获取payload
|
||||
* @param {string} token JWT Token
|
||||
* @returns {object|null} payload数据
|
||||
*/
|
||||
function getPayloadFromToken(token) {
|
||||
try {
|
||||
if (!token) return null;
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const encodedPayload = parts[1];
|
||||
const decodedPayload = atob(encodedPayload);
|
||||
return JSON.parse(decodedPayload);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JWT Token中获取用户ID
|
||||
* @param {string} token JWT Token
|
||||
* @returns {number|null} 用户ID
|
||||
*/
|
||||
export function getUserIdFromToken(token) {
|
||||
const payload = getPayloadFromToken(token);
|
||||
return payload ? parseInt(payload.sub) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JWT Token中获取用户角色ID
|
||||
* @param {string} token JWT Token
|
||||
* @returns {number|null} 角色ID
|
||||
*/
|
||||
export function getRoleIdFromToken(token) {
|
||||
const payload = getPayloadFromToken(token);
|
||||
return payload ? payload.role_id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JWT Token中获取用户名
|
||||
* @param {string} token JWT Token
|
||||
* @returns {string|null} 用户名
|
||||
*/
|
||||
export function getUsernameFromToken(token) {
|
||||
const payload = getPayloadFromToken(token);
|
||||
return payload ? payload.user : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证token是否过期
|
||||
* @param {string} token JWT Token
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
export function isTokenValid(token) {
|
||||
const payload = getPayloadFromToken(token);
|
||||
if (!payload) return false;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return payload.exp > now;
|
||||
}
|
||||
|
||||
// JwtUtil对象,提供便捷的静态方法调用
|
||||
export const JwtUtil = {
|
||||
/**
|
||||
* 从localStorage获取token并解析用户ID
|
||||
* @returns {number|null} 用户ID
|
||||
*/
|
||||
getUserIdFromToken() {
|
||||
const token = localStorage.getItem('token');
|
||||
return getUserIdFromToken(token);
|
||||
},
|
||||
|
||||
/**
|
||||
* 从localStorage获取token并解析角色ID
|
||||
* @returns {number|null} 角色ID
|
||||
*/
|
||||
getRoleIdFromToken() {
|
||||
const token = localStorage.getItem('token');
|
||||
return getRoleIdFromToken(token);
|
||||
},
|
||||
|
||||
/**
|
||||
* 从localStorage获取token并解析用户名
|
||||
* @returns {string|null} 用户名
|
||||
*/
|
||||
getUsernameFromToken() {
|
||||
const token = localStorage.getItem('token');
|
||||
return getUsernameFromToken(token);
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证localStorage中的token是否有效
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
isTokenValid() {
|
||||
const token = localStorage.getItem('token');
|
||||
return isTokenValid(token);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,833 @@
|
||||
<template>
|
||||
<div class="forward-container">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">转发管理</h1>
|
||||
<div class="header-actions">
|
||||
<!-- 搜索框 -->
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索转发名称或远程地址"
|
||||
prefix-icon="el-icon-search"
|
||||
@keyup.enter.native="handleSearch"
|
||||
clearable
|
||||
style="width: 250px; margin-right: 15px;"
|
||||
>
|
||||
<el-button slot="append" icon="el-icon-search" @click="handleSearch"></el-button>
|
||||
</el-input>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
icon="el-icon-plus"
|
||||
@click="handleAdd"
|
||||
class="add-btn"
|
||||
>
|
||||
新增转发
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 转发列表 -->
|
||||
<div class="table-container">
|
||||
<div v-loading="loading" class="cards-container">
|
||||
<div v-if="forwardList.length === 0 && !loading" class="empty-state">
|
||||
<el-empty description="暂无转发配置"></el-empty>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="forward in forwardList"
|
||||
:key="forward.id"
|
||||
class="forward-card"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<i class="el-icon-connection"></i>
|
||||
{{ forward.name }}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<!-- 服务开关 -->
|
||||
<el-switch
|
||||
v-model="forward.serviceRunning"
|
||||
:loading="forward.switchLoading"
|
||||
:disabled="forward.status !== 1 && forward.status !== 0"
|
||||
active-color="#13ce66"
|
||||
inactive-color="#ff4949"
|
||||
@change="handleServiceToggle(forward)"
|
||||
style="margin-right: 10px;"
|
||||
></el-switch>
|
||||
|
||||
<el-button
|
||||
size="mini"
|
||||
type="primary"
|
||||
icon="el-icon-edit"
|
||||
@click="handleEdit(forward)"
|
||||
circle
|
||||
></el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="danger"
|
||||
icon="el-icon-delete"
|
||||
@click="handleDelete(forward)"
|
||||
circle
|
||||
></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<div class="info-item">
|
||||
<span class="label">隧道名称:</span>
|
||||
<span class="value">{{ forward.tunnelName || '未知隧道' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">状态:</span>
|
||||
<el-tag
|
||||
:type="getStatusType(forward.status)"
|
||||
size="small"
|
||||
>
|
||||
{{ getStatusText(forward.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-item">
|
||||
<span class="label">入口地址:</span>
|
||||
<span class="value">{{ forward.inIp}}:{{ forward.inPort }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">目标地址:</span>
|
||||
<span class="value">{{ forward.remoteAddr}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-item full-width">
|
||||
<span class="label">创建时间:</span>
|
||||
<span class="value">{{ forward.createdTime | dateFormat }}</span>
|
||||
</div>
|
||||
<div class="info-item full-width" v-admin>
|
||||
<span class="label">归属账号:</span>
|
||||
<span class="value">{{ forward.userName}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-item">
|
||||
<span class="label">流量统计:</span>
|
||||
<div class="flow-stats-mini">
|
||||
<div class="flow-stat-item">
|
||||
<span class="flow-stat-label">入站:</span>
|
||||
<span class="flow-stat-value in">{{ formatFlow(forward.inFlow || 0) }}</span>
|
||||
</div>
|
||||
<div class="flow-stat-item">
|
||||
<span class="flow-stat-label">出站:</span>
|
||||
<span class="flow-stat-value out">{{ formatFlow(forward.outFlow || 0) }}</span>
|
||||
</div>
|
||||
<div class="flow-stat-item">
|
||||
<span class="flow-stat-label">总计:</span>
|
||||
<span class="flow-stat-value total">{{ formatFlow((forward.inFlow || 0) + (forward.outFlow || 0)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑转发对话框 -->
|
||||
<el-dialog
|
||||
:title="dialogTitle"
|
||||
:visible.sync="dialogVisible"
|
||||
width="600px"
|
||||
@close="resetForm"
|
||||
>
|
||||
<el-form
|
||||
:model="forwardForm"
|
||||
:rules="rules"
|
||||
ref="forwardForm"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="转发名称" prop="name">
|
||||
<el-input
|
||||
v-model="forwardForm.name"
|
||||
placeholder="请输入转发名称"
|
||||
clearable
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择隧道" prop="tunnelId">
|
||||
<el-select
|
||||
v-model="forwardForm.tunnelId"
|
||||
placeholder="请选择关联的隧道"
|
||||
style="width: 100%;"
|
||||
@change="handleTunnelChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="tunnel in tunnelList"
|
||||
:key="tunnel.id || tunnel.tunnelId"
|
||||
:label="getTunnelDisplayName(tunnel)"
|
||||
:value="tunnel.id || tunnel.tunnelId"
|
||||
></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="远程地址" prop="remoteAddr">
|
||||
<el-input
|
||||
v-model="forwardForm.remoteAddr"
|
||||
placeholder="例如: 192.168.1.100:8080"
|
||||
clearable
|
||||
>
|
||||
<template slot="prepend">目标</template>
|
||||
</el-input>
|
||||
<div class="form-hint">
|
||||
格式: IP:端口 或 域名:端口
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
</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 {
|
||||
createForward,
|
||||
getForwardList,
|
||||
updateForward,
|
||||
deleteForward,
|
||||
userTunnel,
|
||||
getTunnelList,
|
||||
pauseForwardService,
|
||||
resumeForwardService
|
||||
} from "@/api";
|
||||
|
||||
export default {
|
||||
name: "Forward",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
submitLoading: false,
|
||||
searchKeyword: '',
|
||||
forwardList: [],
|
||||
tunnelList: [],
|
||||
selectedTunnel: null,
|
||||
|
||||
// 对话框状态
|
||||
dialogVisible: false,
|
||||
dialogTitle: '',
|
||||
isEdit: false,
|
||||
|
||||
// 表单数据
|
||||
forwardForm: {
|
||||
id: null,
|
||||
userId: null,
|
||||
name: '',
|
||||
tunnelId: null,
|
||||
remoteAddr: ''
|
||||
},
|
||||
|
||||
// 表单验证规则
|
||||
rules: {
|
||||
name: [
|
||||
{ required: true, message: '请输入转发名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
tunnelId: [
|
||||
{ required: true, message: '请选择关联隧道', trigger: 'change' }
|
||||
],
|
||||
remoteAddr: [
|
||||
{ required: true, message: '请输入远程地址', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[\w\.-]+:\d+$/,
|
||||
message: '请输入正确的地址格式,如: 192.168.1.100:8080',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.loadForwardList();
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载转发列表
|
||||
async loadForwardList() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await getForwardList();
|
||||
if (res.code === 0) {
|
||||
// 直接使用后端返回的状态数据
|
||||
this.forwardList = res.data.map(forward => ({
|
||||
...forward,
|
||||
serviceRunning: forward.status === 1, // 根据 status 初始化开关状态
|
||||
switchLoading: false // 开关加载状态
|
||||
}));
|
||||
} else {
|
||||
this.$message.error(res.msg || '获取转发列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载转发列表失败:', error);
|
||||
this.$message.error('加载转发列表失败');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 加载隧道列表
|
||||
async loadTunnelList(callback, forceReload = false) {
|
||||
try {
|
||||
// 如果已经有隧道数据且不强制重新加载,直接返回
|
||||
if (this.tunnelList.length > 0 && !forceReload) {
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 先尝试获取用户可用的隧道权限列表
|
||||
let res = await userTunnel();
|
||||
if (res.code === 0 && res.data && res.data.length > 0) {
|
||||
// 如果是普通用户,使用用户隧道权限列表
|
||||
this.tunnelList = res.data;
|
||||
// 如果有回调函数,在隧道列表加载完成后执行
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
throw new Error(res.msg || '没有可用的隧道');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载隧道列表失败:', error);
|
||||
this.$message.error('加载隧道列表失败');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
|
||||
// 搜索功能
|
||||
handleSearch() {
|
||||
if (!this.searchKeyword.trim()) {
|
||||
this.loadForwardList();
|
||||
return;
|
||||
}
|
||||
|
||||
// 实现本地搜索过滤
|
||||
this.loading = true;
|
||||
setTimeout(() => {
|
||||
const filtered = this.forwardList.filter(forward =>
|
||||
forward.name.includes(this.searchKeyword) ||
|
||||
forward.remoteAddr.includes(this.searchKeyword)
|
||||
);
|
||||
this.forwardList = filtered;
|
||||
this.loading = false;
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// 新增转发
|
||||
async handleAdd() {
|
||||
this.isEdit = false;
|
||||
this.dialogTitle = '新增转发';
|
||||
|
||||
try {
|
||||
// 只在没有隧道数据时才加载
|
||||
await this.loadTunnelList();
|
||||
this.dialogVisible = true;
|
||||
} catch (error) {
|
||||
this.$message.error('加载隧道列表失败,无法新增');
|
||||
}
|
||||
},
|
||||
|
||||
// 编辑转发
|
||||
async handleEdit(row) {
|
||||
this.isEdit = true;
|
||||
this.dialogTitle = '编辑转发';
|
||||
|
||||
try {
|
||||
// 只在没有隧道数据时才加载
|
||||
await this.loadTunnelList();
|
||||
|
||||
// 隧道列表加载完成后,设置表单数据并弹出对话框
|
||||
this.forwardForm = {
|
||||
...row,
|
||||
userId: row.userId // 确保userId被正确设置
|
||||
};
|
||||
this.handleTunnelChange(row.tunnelId);
|
||||
this.dialogVisible = true;
|
||||
} catch (error) {
|
||||
this.$message.error('加载隧道列表失败,无法编辑');
|
||||
}
|
||||
},
|
||||
|
||||
// 删除转发
|
||||
async handleDelete(row) {
|
||||
try {
|
||||
await this.$confirm(`确定要删除转发 "${row.name}" 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
const res = await deleteForward(row.id);
|
||||
if (res.code === 0) {
|
||||
this.$message.success('删除成功');
|
||||
this.loadForwardList();
|
||||
} else {
|
||||
this.$message.error(res.msg || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除转发失败:', error);
|
||||
this.$message.error('删除失败');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 隧道选择变化处理
|
||||
handleTunnelChange(tunnelId) {
|
||||
this.selectedTunnel = this.tunnelList.find(tunnel =>
|
||||
tunnel.id === tunnelId || tunnel.tunnelId === tunnelId
|
||||
) || null;
|
||||
},
|
||||
|
||||
// 提交表单
|
||||
async handleSubmit() {
|
||||
try {
|
||||
await this.$refs.forwardForm.validate();
|
||||
|
||||
this.submitLoading = true;
|
||||
let res;
|
||||
|
||||
if (this.isEdit) {
|
||||
// 更新时确保包含必要字段
|
||||
const updateData = {
|
||||
id: this.forwardForm.id,
|
||||
userId: this.forwardForm.userId,
|
||||
name: this.forwardForm.name,
|
||||
tunnelId: this.forwardForm.tunnelId,
|
||||
remoteAddr: this.forwardForm.remoteAddr
|
||||
};
|
||||
res = await updateForward(updateData);
|
||||
} else {
|
||||
// 创建时不需要id和userId(后端会自动设置)
|
||||
const createData = {
|
||||
name: this.forwardForm.name,
|
||||
tunnelId: this.forwardForm.tunnelId,
|
||||
remoteAddr: this.forwardForm.remoteAddr
|
||||
};
|
||||
res = await createForward(createData);
|
||||
}
|
||||
|
||||
if (res.code === 0) {
|
||||
this.$message.success(this.isEdit ? '修改成功' : '创建成功');
|
||||
this.dialogVisible = false;
|
||||
await this.loadForwardList();
|
||||
} else {
|
||||
this.$message.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== false) { // 表单验证失败时不显示错误消息
|
||||
console.error('提交失败:', error);
|
||||
this.$message.error('操作失败');
|
||||
}
|
||||
} finally {
|
||||
this.submitLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 重置表单
|
||||
resetForm() {
|
||||
this.forwardForm = {
|
||||
id: null,
|
||||
userId: null,
|
||||
name: '',
|
||||
tunnelId: null,
|
||||
remoteAddr: ''
|
||||
};
|
||||
this.selectedTunnel = null;
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.forwardForm) {
|
||||
this.$refs.forwardForm.clearValidate();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 获取隧道显示名称
|
||||
getTunnelDisplayName(tunnel) {
|
||||
if (!tunnel) return '未知隧道';
|
||||
|
||||
// 处理用户隧道权限列表的数据结构
|
||||
if (tunnel.tunnelId) {
|
||||
const tunnelInfo = this.tunnelList.find(t => t.id === tunnel.tunnelId);
|
||||
if (tunnelInfo && tunnelInfo.ip && tunnelInfo.port) {
|
||||
return `${tunnelInfo.name || tunnel.tunnelId} (${tunnelInfo.ip}:${tunnelInfo.port})`;
|
||||
}
|
||||
return `隧道ID: ${tunnel.tunnelId}`;
|
||||
}
|
||||
|
||||
// 处理直接隧道数据结构
|
||||
if (tunnel.name) {
|
||||
if (tunnel.ip && tunnel.port) {
|
||||
return `${tunnel.name} (${tunnel.ip}:${tunnel.port})`;
|
||||
}
|
||||
return tunnel.name;
|
||||
}
|
||||
|
||||
return `隧道ID: ${tunnel.id || 'unknown'}`;
|
||||
},
|
||||
|
||||
formatFlow(value) {
|
||||
if (value === 0) return '0';
|
||||
if (value < 1024) return value + ' B';
|
||||
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB';
|
||||
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
},
|
||||
|
||||
// 获取状态类型
|
||||
getStatusType(status) {
|
||||
switch (parseInt(status)) {
|
||||
case 1:
|
||||
return 'success'; // 绿色 - 正常
|
||||
case 0:
|
||||
return 'danger'; // 红色 - 停用
|
||||
case -1:
|
||||
return 'danger'; // 红色 - 停用
|
||||
default:
|
||||
return 'info'; // 蓝色 - 未知状态
|
||||
}
|
||||
},
|
||||
|
||||
// 获取状态文本
|
||||
getStatusText(status) {
|
||||
switch (parseInt(status)) {
|
||||
case 1:
|
||||
return '正常';
|
||||
case 0:
|
||||
return '停用';
|
||||
case -1:
|
||||
return '异常';
|
||||
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 处理服务开关
|
||||
async handleServiceToggle(forward) {
|
||||
// 检查转发状态,只有状态为1(正常)或0(暂停)时才能操作
|
||||
if (forward.status !== 1 && forward.status !== 0) {
|
||||
this.$message.warning('转发状态异常,无法操作');
|
||||
// 恢复开关状态
|
||||
this.$set(forward, 'serviceRunning', !forward.serviceRunning);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetState = forward.serviceRunning;
|
||||
|
||||
try {
|
||||
// 设置加载状态
|
||||
this.$set(forward, 'switchLoading', true);
|
||||
|
||||
let res;
|
||||
if (targetState) {
|
||||
// 启动服务
|
||||
res = await resumeForwardService(forward.id);
|
||||
} else {
|
||||
// 暂停服务
|
||||
res = await pauseForwardService(forward.id);
|
||||
}
|
||||
|
||||
if (res.code === 0) {
|
||||
this.$message.success(targetState ? '服务已启动' : '服务已暂停');
|
||||
// 更新转发状态
|
||||
forward.status = targetState ? 1 : 0;
|
||||
} else {
|
||||
// 操作失败,恢复开关状态
|
||||
this.$set(forward, 'serviceRunning', !targetState);
|
||||
this.$message.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('服务开关操作失败:', error);
|
||||
// 操作失败,恢复开关状态
|
||||
this.$set(forward, 'serviceRunning', !targetState);
|
||||
this.$message.error('网络错误,操作失败');
|
||||
} finally {
|
||||
// 取消加载状态
|
||||
this.$set(forward, 'switchLoading', false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
filters: {
|
||||
dateFormat(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.forward-container {
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.tunnel-info {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Element UI 样式调整 */
|
||||
.el-table {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-button--mini {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.el-input-group__prepend {
|
||||
background-color: #f5f7fa;
|
||||
border-color: #dcdfe6;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.cards-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.forward-card {
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
padding: 24px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.forward-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title i {
|
||||
margin-right: 8px;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-item.full-width {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-item:not(:last-child) {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.flow-stats-mini {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flow-stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flow-stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.flow-stat-value {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.flow-stat-value.in {
|
||||
color: #67C23A;
|
||||
}
|
||||
|
||||
.flow-stat-value.out {
|
||||
color: #E6A23C;
|
||||
}
|
||||
|
||||
.flow-stat-value.total {
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
/* 服务开关样式优化 */
|
||||
.el-switch {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.el-switch__label {
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-switch__label.is-active {
|
||||
color: #13ce66;
|
||||
}
|
||||
|
||||
.card-actions .el-switch {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
/* 禁用状态的开关样式 */
|
||||
.el-switch.is-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.el-switch.is-disabled .el-switch__label {
|
||||
color: #c0c4cc !important;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-header style="height: 50px;">
|
||||
<span style="line-height: 50px">管理后台</span>
|
||||
<div class="header-avatar">
|
||||
<el-avatar style="margin-right: 10px" size="medium" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"></el-avatar>
|
||||
<el-dropdown>
|
||||
<span class="el-dropdown-link" style="color: white">{{name}}</span>
|
||||
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item @click.native="showPasswordDialog">
|
||||
<i class="el-icon-key"></i>
|
||||
修改密码
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click.native="logout">
|
||||
<i class="el-icon-switch-button"></i>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-container>
|
||||
<el-aside width="200px">
|
||||
<el-menu
|
||||
:default-active="path"
|
||||
class="el-menu-vertical-demo">
|
||||
|
||||
<router-link to="/index">
|
||||
<el-menu-item index="/index" @click="save('/index')">
|
||||
<template slot="title">
|
||||
<i class="el-icon-box"></i>
|
||||
<span slot="title">首页</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/limit" v-admin>
|
||||
<el-menu-item index="/limit" @click="save('/limit')">
|
||||
<template slot="title">
|
||||
<i class="el-icon-box"></i>
|
||||
<span slot="title">限速管理</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/forward">
|
||||
<el-menu-item index="/forward" @click="save('/forward')">
|
||||
<template slot="title">
|
||||
<i class="el-icon-postcard"></i>
|
||||
<span slot="title">转发管理</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/tunnel" v-admin>
|
||||
<el-menu-item index="/tunnel" @click="save('/tunnel')">
|
||||
<template slot="title">
|
||||
<i class="el-icon-postcard"></i>
|
||||
<span slot="title">隧道管理</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/node" v-admin>
|
||||
<el-menu-item index="/node" @click="save('/node')">
|
||||
<template slot="title">
|
||||
<i class="el-icon-postcard"></i>
|
||||
<span slot="title">节点监控</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/user" v-admin>
|
||||
<el-menu-item index="/user" @click="save('/user')">
|
||||
<template slot="title">
|
||||
<i class="el-icon-user"></i>
|
||||
<span slot="title">用户管理</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</router-link>
|
||||
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
<div style="margin: 15px 15px;">
|
||||
<router-view/>
|
||||
|
||||
<div style="background-color: white; height: 50px; position: absolute; bottom: 0;left: 199px;right: 0;line-height: 50px; padding-left: 30px;font-size: 12px;color:#58666e;">
|
||||
© 2023 Copyright.
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<el-dialog title="修改密码" :visible.sync="passwordDialogVisible" width="400px" :before-close="handlePasswordDialogClose">
|
||||
<el-form :model="passwordForm" :rules="passwordRules" ref="passwordForm" label-width="100px">
|
||||
<el-form-item label="当前密码" prop="currentPassword">
|
||||
<el-input v-model="passwordForm.currentPassword" type="password" placeholder="请输入当前密码" show-password clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input v-model="passwordForm.newPassword" type="password" placeholder="请输入新密码" show-password clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input v-model="passwordForm.confirmPassword" type="password" placeholder="请再次输入新密码" show-password clearable></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="handlePasswordDialogClose">取 消</el-button>
|
||||
<el-button type="primary" @click="submitPasswordChange" :loading="passwordLoading">确 定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { updatePassword } from '@/api'
|
||||
|
||||
export default {
|
||||
name: "home",
|
||||
data(){
|
||||
return{
|
||||
path:'',
|
||||
name: '',
|
||||
// 修改密码相关数据
|
||||
passwordDialogVisible: false,
|
||||
passwordLoading: false,
|
||||
passwordForm: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
},
|
||||
passwordRules: {
|
||||
currentPassword: [
|
||||
{ required: true, message: '请输入当前密码', trigger: 'blur' },
|
||||
{ min: 1, message: '密码不能为空', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '新密码长度不能少于6位', trigger: 'blur' },
|
||||
{ max: 20, message: '新密码长度不能超过20位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||
{ validator: this.validateConfirmPassword, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.path = localStorage.getItem('e')
|
||||
this.name = localStorage.getItem("name")
|
||||
},
|
||||
methods:{
|
||||
save(e){
|
||||
localStorage.setItem('e',e)
|
||||
this.path = e
|
||||
},
|
||||
logout(){
|
||||
localStorage.clear()
|
||||
this.$router.push("/login")
|
||||
},
|
||||
// 显示修改密码弹窗
|
||||
showPasswordDialog() {
|
||||
this.passwordDialogVisible = true
|
||||
},
|
||||
// 关闭修改密码弹窗
|
||||
handlePasswordDialogClose() {
|
||||
this.passwordDialogVisible = false
|
||||
this.resetPasswordForm()
|
||||
},
|
||||
// 重置修改密码表单
|
||||
resetPasswordForm() {
|
||||
this.passwordForm = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
if (this.$refs.passwordForm) {
|
||||
this.$refs.passwordForm.clearValidate()
|
||||
}
|
||||
},
|
||||
// 确认密码验证器
|
||||
validateConfirmPassword(rule, value, callback) {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== this.passwordForm.newPassword) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
// 提交密码修改
|
||||
async submitPasswordChange() {
|
||||
try {
|
||||
// 表单验证
|
||||
await this.$refs.passwordForm.validate()
|
||||
|
||||
this.passwordLoading = true
|
||||
|
||||
// 调用修改密码接口
|
||||
const response = await updatePassword(this.passwordForm)
|
||||
|
||||
if (response.code === 0) {
|
||||
this.$message.success(response.msg || '密码修改成功')
|
||||
this.handlePasswordDialogClose()
|
||||
|
||||
// 提示用户重新登录
|
||||
this.$confirm('密码修改成功,请重新登录', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
type: 'success',
|
||||
showCancelButton: false
|
||||
}).then(() => {
|
||||
this.logout()
|
||||
}).catch(() => {
|
||||
this.logout()
|
||||
})
|
||||
} else {
|
||||
this.$message.error(response.msg || '密码修改失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'validate') {
|
||||
this.$message.error('修改密码时发生错误')
|
||||
console.error('修改密码错误:', error)
|
||||
}
|
||||
} finally {
|
||||
this.passwordLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-container {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-avatar {
|
||||
float: right;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-dropdown-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-header {
|
||||
background-color: #2F54EB;
|
||||
color: white;
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
.el-aside {
|
||||
color: white;
|
||||
line-height: 200px;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
padding: 0;
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.el-menu-vertical-demo {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 修改密码弹窗样式 */
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,926 @@
|
||||
<template>
|
||||
<div class="package-container">
|
||||
|
||||
<div class="overview-section">
|
||||
<div class="overview-card">
|
||||
<div class="card-header">
|
||||
<h3>账户概览</h3>
|
||||
<el-tag v-if="userInfo.expTime" :type="getExpStatus(userInfo.expTime).type">
|
||||
{{ getExpStatus(userInfo.expTime).text }}
|
||||
</el-tag>
|
||||
<el-tag v-else type="success">永久账户</el-tag>
|
||||
</div>
|
||||
<div class="overview-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">
|
||||
<i class="el-icon-pie-chart"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ formatFlow(userInfo.flow, 'gb') }}</div>
|
||||
<div class="stat-label">总流量配额</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">
|
||||
<i class="el-icon-upload"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ formatFlow(calculateUserTotalUsedFlow()) }}</div>
|
||||
<div class="stat-label">已用流量</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">
|
||||
<i class="el-icon-connection"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ userInfo.num || 0 }}</div>
|
||||
<div class="stat-label">转发配额</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">
|
||||
<i class="el-icon-s-operation"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ totalUsedForwards }}</div>
|
||||
<div class="stat-label">已用转发</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item" v-if="userInfo.flowResetTime">
|
||||
<div class="stat-icon">
|
||||
<i class="el-icon-refresh"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ formatResetTime(userInfo.flowResetTime) }}</div>
|
||||
<div class="stat-label">流量重置时间</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-section" v-if="userInfo.flow > 0">
|
||||
<div class="progress-item">
|
||||
<div class="progress-header">
|
||||
<span>流量使用率</span>
|
||||
<span>{{ calculateUsagePercentage('flow').toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="calculateUsagePercentage('flow')"
|
||||
:status="getUsageStatus('flow')"
|
||||
:stroke-width="8"
|
||||
></el-progress>
|
||||
</div>
|
||||
<div class="progress-item">
|
||||
<div class="progress-header">
|
||||
<span>转发使用率</span>
|
||||
<span>{{ calculateUsagePercentage('forwards').toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="calculateUsagePercentage('forwards')"
|
||||
:status="getUsageStatus('forwards')"
|
||||
:stroke-width="8"
|
||||
></el-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隧道权限列表 -->
|
||||
<div class="tunnels-section">
|
||||
<h3 class="section-title">隧道权限</h3>
|
||||
<div v-loading="loading" class="tunnels-grid">
|
||||
<div v-if="userTunnels.length === 0 && !loading" class="empty-state">
|
||||
<el-empty description="暂无隧道权限"></el-empty>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="tunnel in userTunnels"
|
||||
:key="tunnel.id"
|
||||
class="tunnel-card"
|
||||
>
|
||||
<div class="tunnel-header">
|
||||
<div class="tunnel-name">
|
||||
<i class="el-icon-link"></i>
|
||||
{{ tunnel.tunnelName }}
|
||||
</div>
|
||||
<div class="tunnel-status">
|
||||
<el-tag v-if="tunnel.expTime" :type="getTunnelExpStatus(tunnel.expTime).type" size="mini">
|
||||
{{ getTunnelExpStatus(tunnel.expTime).text }}
|
||||
</el-tag>
|
||||
<el-tag v-else type="success" size="mini">永久</el-tag>
|
||||
<el-tag v-if="tunnel.tunnelFlow === 1" type="info" size="mini">单向计费</el-tag>
|
||||
<el-tag v-else type="warning" size="mini">双向计费</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tunnel-stats">
|
||||
<div class="tunnel-stat-row">
|
||||
<div class="tunnel-stat-item">
|
||||
<span class="stat-label">流量配额:</span>
|
||||
<span class="stat-value">{{ formatFlow(tunnel.flow, 'gb') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-stat-item">
|
||||
<span class="stat-label">已用流量:</span>
|
||||
<span class="stat-value used">{{ formatFlow(calculateTunnelUsedFlow(tunnel)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tunnel-stat-row">
|
||||
<div class="tunnel-stat-item">
|
||||
<span class="stat-label">转发配额:</span>
|
||||
<span class="stat-value">{{ tunnel.num }}</span>
|
||||
</div>
|
||||
<div class="tunnel-stat-item">
|
||||
<span class="stat-label">已用转发:</span>
|
||||
<span class="stat-value used">{{ getTunnelUsedForwards(tunnel.tunnelId) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tunnel-progress">
|
||||
<div class="progress-mini">
|
||||
<div class="progress-mini-header">
|
||||
<span>流量使用</span>
|
||||
<span>{{ calculateTunnelFlowPercentage(tunnel).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="calculateTunnelFlowPercentage(tunnel)"
|
||||
:status="getTunnelFlowStatus(tunnel)"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
></el-progress>
|
||||
</div>
|
||||
<div class="progress-mini">
|
||||
<div class="progress-mini-header">
|
||||
<span>转发使用</span>
|
||||
<span>{{ calculateTunnelForwardPercentage(tunnel).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="calculateTunnelForwardPercentage(tunnel)"
|
||||
:status="getTunnelForwardStatus(tunnel)"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
></el-progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流量重置时间 -->
|
||||
<div class="tunnel-footer" v-if="tunnel.flowResetTime">
|
||||
<span class="reset-time">
|
||||
<i class="el-icon-time"></i>
|
||||
流量重置: {{ new Date(tunnel.flowResetTime) | dateFormat }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 转发详情 -->
|
||||
<div class="forwards-section">
|
||||
<h3 class="section-title">转发详情</h3>
|
||||
<div v-loading="loading" class="forwards-container">
|
||||
<div v-if="groupedForwards.length === 0 && !loading" class="empty-state">
|
||||
<el-empty description="暂无转发配置"></el-empty>
|
||||
</div>
|
||||
|
||||
<div v-for="group in groupedForwards" :key="group.tunnelName" class="forward-group">
|
||||
<div class="group-header">
|
||||
<h4>{{ group.tunnelName }}</h4>
|
||||
<el-tag type="info" size="mini">{{ group.forwards.length }} 个转发</el-tag>
|
||||
</div>
|
||||
<div class="forward-list">
|
||||
<div v-for="forward in group.forwards" :key="forward.id" class="forward-item">
|
||||
<div class="forward-info">
|
||||
<div class="forward-name">{{ forward.name }}</div>
|
||||
<div class="forward-address">
|
||||
<div class="address-source">{{ forward.inIp }}:{{ forward.inPort }}</div>
|
||||
<div class="address-arrow">↓</div>
|
||||
<div class="address-target">{{ forward.remoteAddr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="forward-traffic">
|
||||
<div class="traffic-item">
|
||||
<span class="traffic-label">入站:</span>
|
||||
<span class="traffic-value in">{{ formatFlow(forward.inFlow || 0) }}</span>
|
||||
</div>
|
||||
<div class="traffic-item">
|
||||
<span class="traffic-label">出站:</span>
|
||||
<span class="traffic-value out">{{ formatFlow(forward.outFlow || 0) }}</span>
|
||||
</div>
|
||||
<div class="traffic-item total">
|
||||
<span class="traffic-label">计费:</span>
|
||||
<span class="traffic-value total">{{ formatFlow(calculateForwardBillingFlow(forward)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getUserPackageInfo } from "@/api";
|
||||
|
||||
export default {
|
||||
name: "Index",
|
||||
data() {
|
||||
return {
|
||||
refreshLoading: false,
|
||||
loading: false,
|
||||
userInfo: {},
|
||||
userTunnels: [],
|
||||
forwardList: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
totalUsedForwards() {
|
||||
return this.forwardList.length;
|
||||
},
|
||||
|
||||
groupedForwards() {
|
||||
const groups = {};
|
||||
this.forwardList.forEach(forward => {
|
||||
const tunnelName = forward.tunnelName || '未知隧道';
|
||||
if (!groups[tunnelName]) {
|
||||
groups[tunnelName] = {
|
||||
tunnelName,
|
||||
forwards: []
|
||||
};
|
||||
}
|
||||
groups[tunnelName].forwards.push(forward);
|
||||
});
|
||||
return Object.values(groups);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadPackageData();
|
||||
},
|
||||
methods: {
|
||||
async loadPackageData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await getUserPackageInfo();
|
||||
if (res.code === 0) {
|
||||
const data = res.data;
|
||||
this.userInfo = data.userInfo;
|
||||
this.userTunnels = data.tunnelPermissions || [];
|
||||
this.forwardList = data.forwards || [];
|
||||
} else {
|
||||
this.$message.error(res.msg || '获取套餐信息失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取套餐信息失败:', error);
|
||||
this.$message.error('获取套餐信息失败');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshData() {
|
||||
this.refreshLoading = true;
|
||||
try {
|
||||
await this.loadPackageData();
|
||||
this.$message.success('数据刷新成功');
|
||||
} catch (error) {
|
||||
this.$message.error('数据刷新失败');
|
||||
} finally {
|
||||
this.refreshLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatFlow(value, unit = 'bytes') {
|
||||
if (unit === 'gb') {
|
||||
return value + ' GB';
|
||||
} else {
|
||||
if (value === 0) return '0 B';
|
||||
if (value < 1024) return value + ' B';
|
||||
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB';
|
||||
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
},
|
||||
|
||||
getExpStatus(expTime) {
|
||||
const now = new Date();
|
||||
const expDate = new Date(expTime);
|
||||
|
||||
if (expDate < now) {
|
||||
return { type: 'danger', text: '已过期' };
|
||||
}
|
||||
|
||||
const diffTime = expDate - now;
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 7) {
|
||||
return { type: 'danger', text: `${diffDays}天后过期` };
|
||||
} else if (diffDays <= 30) {
|
||||
return { type: 'warning', text: `${diffDays}天后过期` };
|
||||
} else {
|
||||
return { type: 'success', text: '有效期充足' };
|
||||
}
|
||||
},
|
||||
|
||||
getTunnelExpStatus(expTime) {
|
||||
return this.getExpStatus(expTime);
|
||||
},
|
||||
|
||||
calculateUsagePercentage(type) {
|
||||
if (type === 'flow') {
|
||||
const totalUsed = this.calculateUserTotalUsedFlow();
|
||||
const totalLimit = (this.userInfo.flow || 0) * 1024 * 1024 * 1024;
|
||||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||||
} else if (type === 'forwards') {
|
||||
const totalUsed = this.totalUsedForwards;
|
||||
const totalLimit = this.userInfo.num || 0;
|
||||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
getUsageStatus(type) {
|
||||
const percentage = this.calculateUsagePercentage(type);
|
||||
if (percentage >= 90) return 'exception';
|
||||
if (percentage >= 70) return 'warning';
|
||||
return 'success';
|
||||
},
|
||||
|
||||
calculateTunnelFlowPercentage(tunnel) {
|
||||
const totalUsed = this.calculateTunnelUsedFlow(tunnel);
|
||||
const totalLimit = (tunnel.flow || 0) * 1024 * 1024 * 1024;
|
||||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||||
},
|
||||
|
||||
calculateTunnelForwardPercentage(tunnel) {
|
||||
const totalUsed = this.getTunnelUsedForwards(tunnel.tunnelId);
|
||||
const totalLimit = tunnel.num || 0;
|
||||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||||
},
|
||||
|
||||
getTunnelFlowStatus(tunnel) {
|
||||
const percentage = this.calculateTunnelFlowPercentage(tunnel);
|
||||
if (percentage >= 90) return 'exception';
|
||||
if (percentage >= 70) return 'warning';
|
||||
return 'success';
|
||||
},
|
||||
|
||||
getTunnelForwardStatus(tunnel) {
|
||||
const percentage = this.calculateTunnelForwardPercentage(tunnel);
|
||||
if (percentage >= 90) return 'exception';
|
||||
if (percentage >= 70) return 'warning';
|
||||
return 'success';
|
||||
},
|
||||
|
||||
getTunnelUsedForwards(tunnelId) {
|
||||
return this.forwardList.filter(forward => forward.tunnelId === tunnelId).length;
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算隧道已用流量
|
||||
* 根据隧道的流量计算类型(tunnelFlow)决定单向还是双向计算
|
||||
*/
|
||||
calculateTunnelUsedFlow(tunnel) {
|
||||
if (!tunnel) return 0;
|
||||
|
||||
const inFlow = tunnel.inFlow || 0; // 下载流量
|
||||
const outFlow = tunnel.outFlow || 0; // 上传流量
|
||||
|
||||
// tunnelFlow: 1-单向计算(仅上传),2-双向计算(上传+下载)
|
||||
if (tunnel.tunnelFlow === 1) {
|
||||
// 单向计算:只计算上传流量(outFlow)
|
||||
return outFlow;
|
||||
} else {
|
||||
// 双向计算或默认:计算上传和下载的总流量
|
||||
return inFlow + outFlow;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算用户总已用流量
|
||||
* 直接使用后端计算好的用户流量统计
|
||||
*/
|
||||
calculateUserTotalUsedFlow() {
|
||||
// 后端已经根据隧道类型计算了实际流量,直接使用
|
||||
return (this.userInfo.inFlow || 0) + (this.userInfo.outFlow || 0);
|
||||
},
|
||||
|
||||
// 格式化重置时间显示
|
||||
formatResetTime(resetDay) {
|
||||
if (!resetDay) return '未设置';
|
||||
|
||||
// 获取当前日期
|
||||
const now = new Date();
|
||||
const currentDay = now.getDate();
|
||||
|
||||
// 计算下次重置还有多少天
|
||||
let daysUntilReset;
|
||||
if (resetDay > currentDay) {
|
||||
daysUntilReset = resetDay - currentDay;
|
||||
} else if (resetDay < currentDay) {
|
||||
// 下个月的重置日
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, resetDay);
|
||||
const diffTime = nextMonth - now;
|
||||
daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
} else {
|
||||
// 今天就是重置日
|
||||
daysUntilReset = 0;
|
||||
}
|
||||
|
||||
if (daysUntilReset === 0) {
|
||||
return `今天重置(${resetDay}号)`;
|
||||
} else if (daysUntilReset === 1) {
|
||||
return `明天重置(${resetDay}号)`;
|
||||
} else {
|
||||
return `${daysUntilReset}天后重置(${resetDay}号)`;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算转发的计费流量
|
||||
* 根据对应隧道的流量计算类型决定单向还是双向计算
|
||||
*/
|
||||
calculateForwardBillingFlow(forward) {
|
||||
if (!forward) return 0;
|
||||
|
||||
// 查找对应的隧道
|
||||
const tunnel = this.userTunnels.find(t => t.tunnelId === forward.tunnelId);
|
||||
if (!tunnel) {
|
||||
// 如果找不到隧道信息,默认使用双向计算
|
||||
return (forward.inFlow || 0) + (forward.outFlow || 0);
|
||||
}
|
||||
|
||||
const inFlow = forward.inFlow || 0; // 下载流量
|
||||
const outFlow = forward.outFlow || 0; // 上传流量
|
||||
|
||||
// 根据隧道的流量计算类型
|
||||
if (tunnel.tunnelFlow === 1) {
|
||||
// 单向计算:只计算上传流量(outFlow)
|
||||
return outFlow;
|
||||
} else {
|
||||
// 双向计算:计算上传和下载的总流量
|
||||
return inFlow + outFlow;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
filters: {
|
||||
dateFormat(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.package-container {
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.overview-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.stat-icon i {
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tunnels-section, .forwards-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.tunnel-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.tunnel-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tunnel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tunnel-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tunnel-name i {
|
||||
margin-right: 8px;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.tunnel-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tunnel-stats {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tunnel-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tunnel-stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tunnel-stat-item:first-child {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-value.used {
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
.tunnel-progress {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-mini {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-mini:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.progress-mini-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tunnel-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.reset-time {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reset-time i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.forwards-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.forward-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.forward-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.group-header h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.forward-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.forward-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid #409EFF;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.forward-item:hover {
|
||||
background: #f8fafe;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.forward-info {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.forward-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.forward-address {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.address-source,
|
||||
.address-target {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 4px 12px;
|
||||
background: #ebeef5;
|
||||
border-radius: 4px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.address-source {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.address-target {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.address-arrow {
|
||||
font-size: 18px;
|
||||
color: #409EFF;
|
||||
font-weight: bold;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.forward-traffic {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.traffic-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.traffic-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.traffic-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.traffic-value.in {
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
.traffic-value.out {
|
||||
color: #fa8c16;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.traffic-value.total {
|
||||
color: #722ed1;
|
||||
background: #f9f0ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.package-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.overview-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.forward-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.forward-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forward-info {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.forward-traffic {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,507 @@
|
||||
<template>
|
||||
<div class="speed-limit-container">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">
|
||||
<i class="el-icon-odometer"></i>
|
||||
限速管理
|
||||
</h1>
|
||||
<div class="header-actions">
|
||||
<!-- 搜索框 -->
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索规则名称"
|
||||
prefix-icon="el-icon-search"
|
||||
@keyup.enter.native="handleSearch"
|
||||
clearable
|
||||
style="width: 250px; margin-right: 15px;"
|
||||
>
|
||||
<el-button slot="append" icon="el-icon-search" @click="handleSearch"></el-button>
|
||||
</el-input>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
icon="el-icon-plus"
|
||||
@click="handleAdd"
|
||||
class="add-btn"
|
||||
>
|
||||
新增限速规则
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
icon="el-icon-refresh"
|
||||
@click="loadSpeedLimits"
|
||||
:loading="loading"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 限速规则列表 -->
|
||||
<div class="table-container">
|
||||
<el-table
|
||||
:data="speedLimitList"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
border
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80"></el-table-column>
|
||||
<el-table-column prop="name" label="规则名称" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="speed" label="速度限制" >
|
||||
<template slot-scope="scope">
|
||||
<el-tag type="info">{{ scope.row.speed }} Mbps</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
|
||||
{{ scope.row.status === 1 ? '运行' : '异常' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tunnelName" label="绑定隧道" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<el-tag type="success" v-if="scope.row.tunnelName">{{ scope.row.tunnelName }}</el-tag>
|
||||
<span v-else class="text-muted">未绑定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdTime" label="创建时间" >
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.createdTime | dateFormat }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedTime" label="更新时间" >
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.updatedTime | dateFormat }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
size="mini"
|
||||
type="primary"
|
||||
icon="el-icon-edit"
|
||||
@click="handleEdit(scope.row)"
|
||||
>
|
||||
</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="danger"
|
||||
icon="el-icon-delete"
|
||||
@click="handleDelete(scope.row)"
|
||||
>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loading && speedLimitList.length === 0" class="empty-state">
|
||||
<el-empty description="暂无限速规则">
|
||||
<el-button type="primary" @click="handleAdd">创建第一个限速规则</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog
|
||||
:title="dialogTitle"
|
||||
:visible.sync="dialogVisible"
|
||||
width="500px"
|
||||
@close="resetForm"
|
||||
>
|
||||
<el-form
|
||||
:model="speedLimitForm"
|
||||
:rules="rules"
|
||||
ref="speedLimitForm"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="规则名称" prop="name">
|
||||
<el-input
|
||||
v-model="speedLimitForm.name"
|
||||
placeholder="请输入限速规则名称"
|
||||
clearable
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="速度限制" prop="speed">
|
||||
<el-input-number
|
||||
v-model="speedLimitForm.speed"
|
||||
:min="1"
|
||||
:max="1000000"
|
||||
placeholder="请输入速度限制"
|
||||
style="width: 100%;"
|
||||
></el-input-number>
|
||||
<div class="form-hint">
|
||||
单位:Mbps
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
|
||||
<el-form-item label="绑定隧道" prop="tunnelId">
|
||||
<el-select
|
||||
v-model="speedLimitForm.tunnelId"
|
||||
placeholder="请选择要绑定的隧道"
|
||||
style="width: 100%;"
|
||||
filterable
|
||||
@change="handleTunnelChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="tunnel in tunnelList"
|
||||
:key="tunnel.id"
|
||||
:label="tunnel.name"
|
||||
:value="tunnel.id"
|
||||
></el-option>
|
||||
</el-select>
|
||||
<div class="form-hint">
|
||||
选择要应用限速规则的隧道
|
||||
</div>
|
||||
</el-form-item>
|
||||
</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 { createSpeedLimit, getSpeedLimitList, updateSpeedLimit, deleteSpeedLimit, getTunnelList } from "@/api";
|
||||
|
||||
export default {
|
||||
name: "Limit",
|
||||
data() {
|
||||
return {
|
||||
speedLimitList: [],
|
||||
tunnelList: [],
|
||||
loading: false,
|
||||
dialogVisible: false,
|
||||
dialogTitle: '',
|
||||
isEdit: false,
|
||||
submitLoading: false,
|
||||
searchKeyword: '',
|
||||
|
||||
// 表单数据
|
||||
speedLimitForm: {
|
||||
id: null,
|
||||
name: '',
|
||||
speed: 100,
|
||||
tunnelId: null,
|
||||
tunnelName: '',
|
||||
status: 1
|
||||
},
|
||||
|
||||
// 表单验证规则
|
||||
rules: {
|
||||
name: [
|
||||
{ required: true, message: '请输入规则名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
speed: [
|
||||
{ required: true, message: '请输入速度限制', trigger: 'blur' },
|
||||
{ type: 'number', min: 1, message: '速度限制必须大于0', trigger: 'blur' }
|
||||
],
|
||||
tunnelId: [
|
||||
{ required: true, message: '请选择要绑定的隧道', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadSpeedLimits();
|
||||
this.loadTunnels();
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载限速规则列表
|
||||
async loadSpeedLimits() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await getSpeedLimitList();
|
||||
if (res.code === 0) {
|
||||
this.speedLimitList = res.data || [];
|
||||
} else {
|
||||
this.$message.error(res.msg || '获取限速规则列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载限速规则列表失败:', error);
|
||||
this.$message.error('加载限速规则列表失败');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 加载隧道列表
|
||||
async loadTunnels() {
|
||||
try {
|
||||
const res = await getTunnelList();
|
||||
if (res.code === 0) {
|
||||
this.tunnelList = res.data || [];
|
||||
} else {
|
||||
console.error('获取隧道列表失败:', res.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载隧道列表失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 搜索功能
|
||||
handleSearch() {
|
||||
if (!this.searchKeyword.trim()) {
|
||||
this.loadSpeedLimits();
|
||||
return;
|
||||
}
|
||||
|
||||
// 实现本地搜索过滤
|
||||
this.loading = true;
|
||||
setTimeout(() => {
|
||||
const filtered = this.speedLimitList.filter(item =>
|
||||
item.name.includes(this.searchKeyword)
|
||||
);
|
||||
this.speedLimitList = filtered;
|
||||
this.loading = false;
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// 处理隧道选择变化
|
||||
handleTunnelChange(tunnelId) {
|
||||
const selectedTunnel = this.tunnelList.find(tunnel => tunnel.id === tunnelId);
|
||||
if (selectedTunnel) {
|
||||
this.speedLimitForm.tunnelName = selectedTunnel.name;
|
||||
} else {
|
||||
this.speedLimitForm.tunnelName = '';
|
||||
}
|
||||
},
|
||||
|
||||
// 新增限速规则
|
||||
handleAdd() {
|
||||
this.isEdit = false;
|
||||
this.dialogTitle = '新增限速规则';
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
|
||||
// 编辑限速规则
|
||||
handleEdit(row) {
|
||||
this.isEdit = true;
|
||||
this.dialogTitle = '编辑限速规则';
|
||||
this.speedLimitForm = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
speed: row.speed,
|
||||
tunnelId: row.tunnelId,
|
||||
tunnelName: row.tunnelName,
|
||||
status: row.status || 1
|
||||
};
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
|
||||
// 删除限速规则
|
||||
async handleDelete(row) {
|
||||
try {
|
||||
await this.$confirm(`确定要删除限速规则 "${row.name}" 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
const res = await deleteSpeedLimit(row.id);
|
||||
if (res.code === 0) {
|
||||
this.$message.success('删除成功');
|
||||
this.loadSpeedLimits();
|
||||
} else {
|
||||
this.$message.error(res.msg || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除限速规则失败:', error);
|
||||
this.$message.error('删除失败');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 提交表单
|
||||
async handleSubmit() {
|
||||
try {
|
||||
await this.$refs.speedLimitForm.validate();
|
||||
|
||||
this.submitLoading = true;
|
||||
let res;
|
||||
|
||||
if (this.isEdit) {
|
||||
res = await updateSpeedLimit(this.speedLimitForm);
|
||||
} else {
|
||||
const { id, ...createData } = this.speedLimitForm;
|
||||
res = await createSpeedLimit(createData);
|
||||
}
|
||||
|
||||
if (res.code === 0) {
|
||||
this.$message.success(this.isEdit ? '修改成功' : '创建成功');
|
||||
this.dialogVisible = false;
|
||||
this.loadSpeedLimits();
|
||||
} else {
|
||||
this.$message.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== false) { // 表单验证失败时不显示错误消息
|
||||
console.error('提交失败:', error);
|
||||
this.$message.error('操作失败');
|
||||
}
|
||||
} finally {
|
||||
this.submitLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 重置表单
|
||||
resetForm() {
|
||||
this.speedLimitForm = {
|
||||
id: null,
|
||||
name: '',
|
||||
speed: 100,
|
||||
tunnelId: null,
|
||||
tunnelName: '',
|
||||
status: 1
|
||||
};
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.speedLimitForm) {
|
||||
this.$refs.speedLimitForm.clearValidate();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
filters: {
|
||||
dateFormat(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.speed-limit-container {
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title i {
|
||||
margin-right: 12px;
|
||||
font-size: 28px;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Element UI 样式调整 */
|
||||
.el-table {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-button--mini {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.speed-limit-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="bg-decoration">
|
||||
<div class="circle circle-1"></div>
|
||||
<div class="circle circle-2"></div>
|
||||
<div class="circle circle-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="login-wrapper">
|
||||
<!-- 登录表单 -->
|
||||
<div class="login-form-container">
|
||||
<div class="login-form">
|
||||
<div class="form-header">
|
||||
<h2 class="form-title">账户登录</h2>
|
||||
<p class="form-subtitle">请输入您的登录凭据</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
:model="loginForm"
|
||||
:rules="rules"
|
||||
ref="loginForm"
|
||||
@keyup.enter.native="handleLogin"
|
||||
class="login-form-content"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<div class="input-wrapper">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
prefix-icon="el-icon-user"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
size="large"
|
||||
class="custom-input"
|
||||
></el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<div class="input-wrapper">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
prefix-icon="el-icon-lock"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
size="large"
|
||||
class="custom-input"
|
||||
></el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="login-btn-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
class="login-btn"
|
||||
size="large"
|
||||
>
|
||||
<span v-if="!loading">立即登录</span>
|
||||
<span v-else>登录中...</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<p class="copyright">© 2024 管理系统. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {login} from "@/api";
|
||||
|
||||
export default {
|
||||
name: 'Login',
|
||||
data() {
|
||||
return {
|
||||
loginForm: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
rules: {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }
|
||||
]
|
||||
},
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleLogin() {
|
||||
this.$refs.loginForm.validate(valid => {
|
||||
if (valid) {
|
||||
this.loading = true;
|
||||
login(this.loginForm).then(res=>{
|
||||
this.loading = false
|
||||
if (res.code !== 0) return this.$message.error(res.msg)
|
||||
localStorage.setItem('token', res.data.token);
|
||||
localStorage.setItem("e", '/index')
|
||||
localStorage.setItem("role_id", res.data.role_id)
|
||||
localStorage.setItem("name", res.data.name)
|
||||
this.$message.success('登录成功');
|
||||
this.$router.push("/index");
|
||||
}).catch(() => {
|
||||
this.loading = false;
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 背景装饰 */
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.circle-1 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
top: 10%;
|
||||
left: 10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.circle-2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
top: 60%;
|
||||
right: 15%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.circle-3 {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
bottom: 20%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(180deg); }
|
||||
}
|
||||
|
||||
/* 主要内容区域 */
|
||||
.login-wrapper {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/* 登录表单 */
|
||||
.login-form-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form-content {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 自定义输入框样式 */
|
||||
.custom-input >>> .el-input__inner {
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
font-size: 16px;
|
||||
padding-left: 45px;
|
||||
transition: all 0.3s ease;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.custom-input >>> .el-input__inner:focus {
|
||||
border-color: #667eea;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.custom-input >>> .el-input__prefix {
|
||||
left: 15px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.custom-input >>> .el-input__suffix {
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
/* 登录按钮 */
|
||||
.login-btn-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 表单底部 */
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-form-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表单验证错误样式 */
|
||||
.login-form-content >>> .el-form-item__error {
|
||||
font-size: 14px;
|
||||
color: #ef4444;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.login-btn.is-loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user