mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-01 00:26:38 +08:00
1359 lines
35 KiB
Vue
1359 lines
35 KiB
Vue
<template>
|
||
<div class="forward-container">
|
||
<!-- 简化的页面头部 -->
|
||
<div class="action-buttons" style="margin-bottom: 10px;">
|
||
|
||
|
||
<el-button
|
||
type="primary"
|
||
icon="el-icon-plus"
|
||
@click="handleAdd"
|
||
class="add-btn"
|
||
>
|
||
{{ isMobile ? '添加' : '新增转发' }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 转发列表 -->
|
||
<div class="table-container">
|
||
<div v-loading="loading" class="cards-container">
|
||
<!-- 空状态 -->
|
||
<div v-if="forwardList.length === 0 && !loading" class="empty-state" style="margin-top: 10px;">
|
||
<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>
|
||
<span class="title-text">{{ forward.name }}</span>
|
||
</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)"
|
||
:loading="forward.deleteLoading"
|
||
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 full-width">
|
||
<div class="address-item">
|
||
<div class="label-with-copy">
|
||
<span class="label">入口地址:</span>
|
||
<el-button
|
||
size="mini"
|
||
type="text"
|
||
icon="el-icon-copy-document"
|
||
@click="copyToClipboard(formatInAddress(forward.inIp, forward.inPort), '入口地址')"
|
||
class="copy-btn"
|
||
title="复制入口地址"
|
||
></el-button>
|
||
</div>
|
||
<div class="address-value">
|
||
<span class="value address-text" :title="formatInAddress(forward.inIp, forward.inPort)">
|
||
{{ formatInAddress(forward.inIp, forward.inPort) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-row">
|
||
<div class="info-item full-width">
|
||
<div class="address-item">
|
||
<div class="label-with-copy">
|
||
<span class="label">目标地址:</span>
|
||
<el-button
|
||
size="mini"
|
||
type="text"
|
||
icon="el-icon-copy-document"
|
||
@click="copyToClipboard(forward.remoteAddr, '目标地址')"
|
||
class="copy-btn"
|
||
title="复制目标地址"
|
||
></el-button>
|
||
</div>
|
||
<div class="address-value">
|
||
<span class="value address-text" :title="forward.remoteAddr">
|
||
{{ forward.remoteAddr }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</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="isMobile ? '90%' : '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="inPort">
|
||
<el-input
|
||
v-model.number="forwardForm.inPort"
|
||
type="number"
|
||
placeholder="留空自动分配"
|
||
clearable
|
||
:min="1"
|
||
:max="65535"
|
||
>
|
||
<template slot="prepend">端口</template>
|
||
</el-input>
|
||
<div class="form-hint" v-if="selectedTunnel">
|
||
允许范围: {{ selectedTunnel.inPortSta }}-{{ selectedTunnel.inPortEnd }},留空将自动分配可用端口
|
||
</div>
|
||
<div class="form-hint" v-else>
|
||
请先选择隧道以查看端口范围,留空将自动分配可用端口
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="远程地址" prop="remoteAddr">
|
||
<el-input
|
||
v-model="forwardForm.remoteAddr"
|
||
placeholder="例如: 192.168.1.100:8080 或 [2001:db8::1]:8080"
|
||
clearable
|
||
>
|
||
<template slot="prepend">目标</template>
|
||
</el-input>
|
||
<div class="form-hint">
|
||
格式: IPv4(IP:端口)、IPv6([IPv6]:端口)或域名:端口
|
||
</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,
|
||
forceDeleteForward,
|
||
userTunnel,
|
||
getTunnelList,
|
||
pauseForwardService,
|
||
resumeForwardService
|
||
} from "@/api";
|
||
import { copyWithMessage } from "@/utils/clipboard";
|
||
|
||
export default {
|
||
name: "Forward",
|
||
data() {
|
||
return {
|
||
loading: false,
|
||
submitLoading: false,
|
||
searchKeyword: '',
|
||
forwardList: [],
|
||
tunnelList: [],
|
||
selectedTunnel: null,
|
||
isMobile: false,
|
||
|
||
// 对话框状态
|
||
dialogVisible: false,
|
||
dialogTitle: '',
|
||
isEdit: false,
|
||
|
||
// 表单数据
|
||
forwardForm: {
|
||
id: null,
|
||
userId: null,
|
||
name: '',
|
||
tunnelId: null,
|
||
inPort: null,
|
||
remoteAddr: ''
|
||
},
|
||
|
||
// 表单验证规则
|
||
rules: {
|
||
name: [
|
||
{ required: true, message: '请输入转发名称', trigger: 'blur' },
|
||
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
|
||
],
|
||
tunnelId: [
|
||
{ required: true, message: '请选择关联隧道', trigger: 'change' }
|
||
],
|
||
inPort: [
|
||
{
|
||
validator: (rule, value, callback) => {
|
||
if (value !== null && value !== undefined && value !== '') {
|
||
// 检查端口号范围
|
||
if (value < 1 || value > 65535) {
|
||
callback(new Error('端口号必须在1-65535之间'));
|
||
return;
|
||
}
|
||
|
||
// 检查是否在隧道允许范围内
|
||
if (this.selectedTunnel) {
|
||
if (value < this.selectedTunnel.inPortSta || value > this.selectedTunnel.inPortEnd) {
|
||
callback(new Error(`端口号必须在${this.selectedTunnel.inPortSta}-${this.selectedTunnel.inPortEnd}范围内`));
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
callback();
|
||
},
|
||
trigger: 'blur'
|
||
}
|
||
],
|
||
remoteAddr: [
|
||
{ required: true, message: '请输入远程地址', trigger: 'blur' },
|
||
{
|
||
validator: (rule, value, callback) => {
|
||
if (value) {
|
||
// IPv4格式:192.168.1.1:8080
|
||
const ipv4Pattern = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?):\d+$/;
|
||
|
||
// IPv6格式(带方括号):[2001:db8::1]:8080
|
||
const ipv6BracketPattern = /^\[([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}\]:\d+$/;
|
||
|
||
// IPv6格式(简化版本,包括::1, ::, ::ffff:192.0.2.1等)
|
||
const ipv6SimplifiedPattern = /^\[(::1|::|::ffff:[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|([0-9a-fA-F]{1,4}:){1,7}:|:([0-9a-fA-F]{1,4}:){1,6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:([0-9a-fA-F]{1,4}:){1,6}[0-9a-fA-F]{1,4})\]:\d+$/;
|
||
|
||
// 域名格式:example.com:8080
|
||
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*:\d+$/;
|
||
|
||
// 更完整的IPv6正则(支持各种压缩格式)
|
||
const ipv6FullPattern = /^\[((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9a-fA-F]{1,4}:){6}(:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){5}(((:[0-9a-fA-F]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){4}(((:[0-9a-fA-F]{1,4}){1,3})|((:[0-9a-fA-F]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){3}(((:[0-9a-fA-F]{1,4}){1,4})|((:[0-9a-fA-F]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){2}(((:[0-9a-fA-F]{1,4}){1,5})|((:[0-9a-fA-F]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){1}(((:[0-9a-fA-F]{1,4}){1,6})|((:[0-9a-fA-F]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-fA-F]{1,4}){1,7})|((:[0-9a-fA-F]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))\]:\d+$/;
|
||
|
||
if (ipv4Pattern.test(value) || ipv6FullPattern.test(value) || domainPattern.test(value)) {
|
||
callback();
|
||
} else {
|
||
callback(new Error('请输入正确的地址格式,如: 192.168.1.100:8080 或 [2001:db8::1]:8080 或 example.com:8080'));
|
||
}
|
||
} else {
|
||
callback();
|
||
}
|
||
},
|
||
trigger: 'blur'
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
|
||
created() {
|
||
this.checkMobile();
|
||
this.loadForwardList();
|
||
},
|
||
|
||
mounted() {
|
||
window.addEventListener('resize', this.checkMobile);
|
||
},
|
||
|
||
beforeDestroy() {
|
||
window.removeEventListener('resize', this.checkMobile);
|
||
},
|
||
|
||
methods: {
|
||
// 检查是否为移动端
|
||
checkMobile() {
|
||
this.isMobile = window.innerWidth <= 768;
|
||
},
|
||
|
||
// 加载转发列表
|
||
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, // 开关加载状态
|
||
deleteLoading: 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被正确设置
|
||
inPort: row.inPort || null // 设置入口端口
|
||
};
|
||
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'
|
||
});
|
||
|
||
// 设置删除loading状态
|
||
this.$set(row, 'deleteLoading', true);
|
||
|
||
const res = await deleteForward(row.id);
|
||
|
||
if (res.code === 0) {
|
||
this.$message.success('删除成功');
|
||
this.loadForwardList();
|
||
} else {
|
||
// 删除失败,询问是否强制删除
|
||
this.handleDeleteFailure(row, res.msg || '删除失败');
|
||
}
|
||
} catch (error) {
|
||
if (error !== 'cancel') {
|
||
console.error('删除转发失败:', error);
|
||
this.handleDeleteFailure(row, '网络错误,删除失败');
|
||
}
|
||
} finally {
|
||
// 取消删除loading状态
|
||
this.$set(row, 'deleteLoading', false);
|
||
}
|
||
},
|
||
|
||
// 处理删除失败,询问是否强制删除
|
||
async handleDeleteFailure(row, errorMsg) {
|
||
try {
|
||
await this.$confirm(
|
||
`常规删除失败:${errorMsg}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务,需自行确认节点端服务已停止,否则可能导致服务残留。`,
|
||
'删除失败 - 强制删除确认',
|
||
{
|
||
confirmButtonText: '强制删除',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
dangerouslyUseHTMLString: false,
|
||
showClose: false
|
||
}
|
||
);
|
||
|
||
// 用户确认强制删除
|
||
this.$set(row, 'deleteLoading', true);
|
||
|
||
const res = await forceDeleteForward(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('强制删除失败');
|
||
}
|
||
} finally {
|
||
this.$set(row, 'deleteLoading', false);
|
||
}
|
||
},
|
||
|
||
// 隧道选择变化处理
|
||
handleTunnelChange(tunnelId) {
|
||
this.selectedTunnel = this.tunnelList.find(tunnel =>
|
||
tunnel.id === tunnelId
|
||
) || null;
|
||
|
||
// 清空端口输入,避免与新隧道的端口范围冲突
|
||
this.forwardForm.inPort = null;
|
||
|
||
// 触发端口字段重新验证
|
||
this.$nextTick(() => {
|
||
if (this.$refs.forwardForm) {
|
||
this.$refs.forwardForm.clearValidate('inPort');
|
||
}
|
||
});
|
||
},
|
||
|
||
// 提交表单
|
||
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,
|
||
inPort: this.forwardForm.inPort || null,
|
||
remoteAddr: this.forwardForm.remoteAddr
|
||
};
|
||
res = await updateForward(updateData);
|
||
} else {
|
||
// 创建时不需要id和userId(后端会自动设置)
|
||
const createData = {
|
||
name: this.forwardForm.name,
|
||
tunnelId: this.forwardForm.tunnelId,
|
||
inPort: this.forwardForm.inPort || null,
|
||
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,
|
||
inPort: null,
|
||
remoteAddr: ''
|
||
};
|
||
this.selectedTunnel = null;
|
||
this.$nextTick(() => {
|
||
if (this.$refs.forwardForm) {
|
||
this.$refs.forwardForm.clearValidate();
|
||
}
|
||
});
|
||
},
|
||
|
||
// 获取隧道显示名称
|
||
getTunnelDisplayName(tunnel) {
|
||
if (!tunnel) return '未知隧道';
|
||
|
||
// 处理隧道数据结构
|
||
if (tunnel.name) {
|
||
// 显示隧道名称和IP信息
|
||
if (tunnel.ip) {
|
||
return `${tunnel.name} (${tunnel.ip})`;
|
||
}
|
||
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';
|
||
},
|
||
|
||
// 格式化入口地址,IPv6需要用[]包裹
|
||
formatInAddress(ip, port) {
|
||
if (!ip || !port) return '';
|
||
|
||
// 检查是否为IPv6地址
|
||
if (ip.includes(':')) {
|
||
// IPv6地址需要用方括号包裹
|
||
return `[${ip}]:${port}`;
|
||
} else {
|
||
// IPv4地址直接拼接
|
||
return `${ip}:${port}`;
|
||
}
|
||
},
|
||
|
||
// 复制到剪贴板
|
||
async copyToClipboard(text, label = '内容') {
|
||
await copyWithMessage(text, label, this);
|
||
},
|
||
|
||
// 获取状态类型
|
||
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;
|
||
}
|
||
|
||
/* 移动端优化 */
|
||
@media (max-width: 768px) {
|
||
.forward-container {
|
||
padding: 10px;
|
||
}
|
||
}
|
||
|
||
.action-buttons {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* 移动端行为按钮样式 */
|
||
@media (max-width: 768px) {
|
||
.action-buttons {
|
||
flex-direction: row;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.action-buttons .el-input {
|
||
min-width: 0;
|
||
}
|
||
|
||
.add-btn {
|
||
flex-shrink: 0;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.add-btn {
|
||
border-radius: 6px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.table-container {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.form-hint {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-top: 5px;
|
||
}
|
||
|
||
.tunnel-info {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.dialog-footer {
|
||
text-align: right;
|
||
}
|
||
|
||
/* Element UI 样式调整 */
|
||
.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;
|
||
}
|
||
|
||
/* 移动端对话框优化 */
|
||
@media (max-width: 768px) {
|
||
.el-dialog {
|
||
border-radius: 8px;
|
||
margin: 20px;
|
||
}
|
||
|
||
.el-dialog__header {
|
||
padding: 15px 20px 10px;
|
||
}
|
||
|
||
.el-dialog__body {
|
||
padding: 10px 20px;
|
||
}
|
||
|
||
.el-dialog__footer {
|
||
padding: 10px 20px 15px;
|
||
}
|
||
|
||
.el-dialog__title {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.el-form-item__label {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.dialog-footer {
|
||
text-align: center;
|
||
}
|
||
|
||
.dialog-footer .el-button {
|
||
min-width: 80px;
|
||
}
|
||
}
|
||
|
||
.cards-container {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 20px;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
/* 移动端卡片容器 */
|
||
@media (max-width: 768px) {
|
||
.cards-container {
|
||
grid-template-columns: 1fr;
|
||
gap: 15px;
|
||
padding: 0;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
overflow: hidden; /* 防止内容撑大容器 */
|
||
}
|
||
|
||
/* 移动端卡片样式 */
|
||
@media (max-width: 768px) {
|
||
.forward-card {
|
||
padding: 16px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||
}
|
||
|
||
.forward-card:hover {
|
||
transform: none;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||
}
|
||
}
|
||
|
||
/* 桌面端悬停效果 */
|
||
@media (min-width: 769px) {
|
||
.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;
|
||
}
|
||
|
||
/* 移动端卡片头部 */
|
||
@media (max-width: 768px) {
|
||
.card-header {
|
||
margin-bottom: 16px;
|
||
padding-bottom: 10px;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
display: flex;
|
||
align-items: center;
|
||
min-width: 0;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 移动端卡片标题 */
|
||
@media (max-width: 768px) {
|
||
.card-title {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.title-text {
|
||
max-width: calc(100% - 60px); /* 为操作按钮留出空间 */
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.card-title i {
|
||
margin-right: 8px;
|
||
color: #409EFF;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.title-text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.card-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 移动端卡片操作 */
|
||
@media (max-width: 768px) {
|
||
.card-actions {
|
||
gap: 6px;
|
||
}
|
||
|
||
.card-actions .el-button--mini {
|
||
padding: 6px;
|
||
}
|
||
}
|
||
|
||
.card-body {
|
||
margin-top: 16px;
|
||
overflow: hidden; /* 防止内容撑大容器 */
|
||
}
|
||
|
||
.info-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.info-row:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* 移动端信息行 */
|
||
@media (max-width: 768px) {
|
||
.info-row {
|
||
margin-bottom: 14px;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
}
|
||
|
||
.info-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0; /* 确保flex子元素可以收缩 */
|
||
overflow: hidden; /* 防止内容撑大容器 */
|
||
}
|
||
|
||
.info-item.full-width {
|
||
flex: 1;
|
||
min-width: 0; /* 确保flex子元素可以收缩 */
|
||
overflow: hidden; /* 防止内容撑大容器 */
|
||
}
|
||
|
||
.info-item:not(:last-child) {
|
||
margin-right: 20px;
|
||
}
|
||
|
||
/* 移动端信息项 */
|
||
@media (max-width: 768px) {
|
||
.info-item:not(:last-child) {
|
||
margin-right: 0;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.info-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.label {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-bottom: 4px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.value {
|
||
font-size: 14px;
|
||
color: #303133;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 移动端字体优化 */
|
||
@media (max-width: 768px) {
|
||
.label {
|
||
font-size: 11px;
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.value {
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.empty-state {
|
||
grid-column: 1 / -1;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 300px;
|
||
}
|
||
|
||
.flow-stats-mini {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* 移动端流量统计 */
|
||
@media (max-width: 768px) {
|
||
.flow-stats-mini {
|
||
gap: 6px;
|
||
}
|
||
}
|
||
|
||
.flow-stat-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 60px;
|
||
}
|
||
|
||
/* 移动端流量统计项 */
|
||
@media (max-width: 480px) {
|
||
.flow-stat-item {
|
||
min-width: 50px;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 移动端流量统计字体 */
|
||
@media (max-width: 768px) {
|
||
.flow-stat-label {
|
||
font-size: 11px;
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.flow-stat-value {
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 地址项样式 */
|
||
.address-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100%;
|
||
min-width: 0; /* 确保flex子元素可以收缩 */
|
||
overflow: hidden; /* 防止内容撑大容器 */
|
||
}
|
||
|
||
.label-with-copy {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.address-value {
|
||
width: 100%;
|
||
min-width: 0; /* 确保flex子元素可以收缩 */
|
||
overflow: hidden; /* 确保容器支持省略号 */
|
||
}
|
||
|
||
.address-text {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
width: 100%;
|
||
max-width: 100%; /* 确保不会超出容器 */
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.copy-btn {
|
||
flex-shrink: 0;
|
||
padding: 2px 6px !important;
|
||
margin: 0 !important;
|
||
min-width: auto !important;
|
||
height: 20px !important;
|
||
opacity: 0.6;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.copy-btn:hover {
|
||
opacity: 1;
|
||
color: #409EFF !important;
|
||
}
|
||
|
||
.copy-btn .el-icon-copy-document {
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 地址项悬停效果 */
|
||
.address-item:hover .copy-btn {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* 移动端复制按钮 */
|
||
@media (max-width: 768px) {
|
||
.copy-btn {
|
||
opacity: 0.8;
|
||
height: 18px !important;
|
||
padding: 1px 4px !important;
|
||
}
|
||
|
||
.copy-btn .el-icon-copy-document {
|
||
font-size: 11px;
|
||
}
|
||
|
||
.address-item:hover .copy-btn {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* 复制对话框样式 */
|
||
::v-deep .copy-dialog .el-message-box__content {
|
||
word-break: break-all;
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
-moz-user-select: text;
|
||
-ms-user-select: text;
|
||
}
|
||
|
||
::v-deep .copy-dialog .el-message-box__message p {
|
||
white-space: pre-wrap;
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
background: #f5f5f5;
|
||
padding: 10px;
|
||
border-radius: 4px;
|
||
margin: 10px 0;
|
||
user-select: text;
|
||
-webkit-user-select: text;
|
||
}
|
||
|
||
/* 移动端全局优化 */
|
||
@media (max-width: 768px) {
|
||
/* 确保内容不会水平滚动 */
|
||
.forward-container * {
|
||
box-sizing: border-box;
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* 强制约束容器宽度 */
|
||
.forward-card,
|
||
.card-body,
|
||
.info-row,
|
||
.info-item,
|
||
.address-item,
|
||
.address-value {
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 优化长文本显示 - 保持省略号不换行 */
|
||
.address-text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* 标题文本约束 */
|
||
.title-text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* 移动端空状态调整 */
|
||
.empty-state {
|
||
padding: 40px 20px;
|
||
}
|
||
|
||
/* 移动端form hint */
|
||
.form-hint {
|
||
font-size: 11px;
|
||
line-height: 1.4;
|
||
}
|
||
}
|
||
|
||
/* 超小屏幕优化 */
|
||
@media (max-width: 480px) {
|
||
.forward-container {
|
||
padding: 8px;
|
||
}
|
||
|
||
.action-buttons {
|
||
gap: 8px;
|
||
}
|
||
|
||
.forward-card {
|
||
padding: 12px;
|
||
}
|
||
|
||
.card-header {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.info-row {
|
||
margin-bottom: 10px;
|
||
}
|
||
}
|
||
|
||
|
||
</style> |