This commit is contained in:
qaq
2025-06-12 15:32:13 +08:00
commit 959ff9e2f2
146 changed files with 35933 additions and 0 deletions
+96
View File
@@ -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>
+51
View File
@@ -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)
+75
View File
@@ -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

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