Files
flvx/vue-frontend/src/views/Forward.vue
T
2025-07-01 15:13:47 +08:00

1957 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="forward-container">
<!-- 页面头部 -->
<div class="header-bar">
<h2 class="page-title">
<i class="el-icon-connection"></i>
转发管理
</h2>
<el-button
type="primary"
size="small"
icon="el-icon-plus"
@click="handleAdd"
>
新增转发
</el-button>
</div>
<!-- 转发列表 -->
<div class="table-container">
<div v-loading="loading" class="cards-container">
<!-- 空状态 -->
<EmptyState
v-if="forwardList.length === 0 && !loading"
description="暂无转发配置"
/>
<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 inline-on-mobile">
<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 inline-on-mobile" v-if="hasMultipleTargetAddresses(forward.remoteAddr)">
<div class="info-item">
<span class="label">负载策略:</span>
<el-tag
:type="getStrategyType(forward.strategy)"
size="small"
>
{{ getStrategyText(forward.strategy) }}
</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(getCopyInAddress(forward.inIp, forward.inPort), '入口地址')"
class="copy-btn"
title="复制入口地址"
></el-button>
</div>
<div class="address-value">
<span
class="value address-text"
@click="showAddressDialog(forward.inIp, forward.inPort, '入口地址')"
:class="{ 'clickable': hasMultipleIps(forward.inIp) }"
>
{{ 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"
@click="showTargetAddressDialog(forward.remoteAddr, '目标地址')"
:class="{ 'clickable': hasMultipleTargetAddresses(forward.remoteAddr) }"
>
{{ formatTargetAddress(forward.remoteAddr) }}
</span>
</div>
</div>
</div>
</div>
<div class="info-row inline-on-mobile">
<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"
type="textarea"
:rows="4"
placeholder="一行一个地址"
clearable
>
</el-input>
<div class="form-hint">
格式: IPv4(IP:端口)、IPv6([IPv6]:端口)或域名:端口<br>
支持多个地址,每行一个地址
</div>
</el-form-item>
<!-- 负载均衡策略选择 -->
<el-form-item
label="负载策略"
prop="strategy"
v-if="getAddressCount(forwardForm.remoteAddr) > 1"
>
<el-select
v-model="forwardForm.strategy"
placeholder="请选择负载均衡策略"
style="width: 100%;"
>
<el-option value="fifo" label="主备模式 - 自上而下"></el-option>
<el-option value="round" label="轮询模式 - 依次轮换"></el-option>
<el-option value="rand" label="随机模式 - 随机选择"></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>
<!-- 地址列表弹窗 -->
<el-dialog
:title="addressDialogTitle"
:visible.sync="addressDialogVisible"
:width="isMobile ? '90%' : '500px'"
class="address-list-dialog"
>
<div class="address-list-container">
<!-- 头部操作 -->
<div class="address-list-header">
<div class="header-info">
<i class="el-icon-connection"></i>
<span>点击地址右侧按钮可单独复制</span>
</div>
<el-button
size="mini"
type="primary"
icon="el-icon-copy-document"
@click="copyAllAddresses"
class="copy-all-btn"
>
复制全部
</el-button>
</div>
<!-- 地址列表 -->
<div class="address-list">
<div
v-for="addressItem in addressList"
:key="addressItem.id"
class="address-list-item"
>
<div class="address-content">
<div class="ip-info">
<i class="el-icon-position"></i>
<span class="ip-text">{{ addressItem.ip }}</span>
</div>
<div class="address-text-full">{{ addressItem.address }}</div>
</div>
<el-button
size="mini"
type="text"
icon="el-icon-copy-document"
:loading="addressItem.copying"
@click="copyAddress(addressItem)"
class="copy-single-btn"
title="复制此地址"
>
复制
</el-button>
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="addressDialogVisible = false">关 闭</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";
import EmptyState from "@/components/EmptyState.vue";
export default {
name: "Forward",
components: {
EmptyState
},
data() {
return {
loading: false,
submitLoading: false,
searchKeyword: '',
forwardList: [],
tunnelList: [],
selectedTunnel: null,
isMobile: false,
// 对话框状态
dialogVisible: false,
dialogTitle: '',
isEdit: false,
// 地址列表弹窗状态
addressDialogVisible: false,
addressDialogTitle: '',
addressList: [],
// 表单数据
forwardForm: {
id: null,
userId: null,
name: '',
tunnelId: null,
inPort: null,
remoteAddr: '',
strategy: 'fifo' // 默认主备模式
},
// 表单验证规则
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) {
// 按行分割地址
const addresses = value.split('\n').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length === 0) {
callback(new Error('请输入至少一个有效地址'));
return;
}
// 地址验证正则
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+$/;
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+$/;
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+$/;
// 验证每个地址
for (let i = 0; i < addresses.length; i++) {
const addr = addresses[i];
if (!ipv4Pattern.test(addr) && !ipv6FullPattern.test(addr) && !domainPattern.test(addr)) {
callback(new Error(`第${i + 1}行地址格式错误: ${addr}\n请使用正确格式,如: 192.168.1.100:8080 或 [2001:db8::1]:8080 或 example.com:8080`));
return;
}
}
callback();
} else {
callback();
}
},
trigger: 'blur'
}
],
strategy: [
{ required: true, message: '请选择负载均衡策略', trigger: 'change' }
]
}
}
},
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, // 设置入口端口
remoteAddr: row.remoteAddr ? row.remoteAddr.split(',').map(addr => addr.trim()).join('\n') : '', // 将逗号分隔转换为换行分隔
strategy: row.strategy || 'fifo' // 设置负载均衡策略,默认主备模式
};
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;
// 只在新增模式下清空端口输入,编辑模式下保留原端口值
if (!this.isEdit) {
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;
// 处理远程地址:将换行符转换为逗号分隔
const processedRemoteAddr = this.forwardForm.remoteAddr
.split('\n')
.map(addr => addr.trim())
.filter(addr => addr)
.join(',');
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: processedRemoteAddr,
strategy: this.getAddressCount(this.forwardForm.remoteAddr) > 1 ? this.forwardForm.strategy : 'fifo'
};
res = await updateForward(updateData);
} else {
// 创建时不需要id和userId(后端会自动设置)
const createData = {
name: this.forwardForm.name,
tunnelId: this.forwardForm.tunnelId,
inPort: this.forwardForm.inPort || null,
remoteAddr: processedRemoteAddr,
strategy: this.getAddressCount(this.forwardForm.remoteAddr) > 1 ? this.forwardForm.strategy : 'fifo'
};
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: '',
strategy: 'fifo'
};
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}`;
}
return tunnel.name;
}
return `隧道ID: ${tunnel.id}`;
},
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需要用[]包裹,支持多IP优化显示
formatInAddress(ipString, port) {
if (!ipString || !port) return '';
// 分割IP字符串,处理多个IP的情况
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length === 0) return '';
// 只有一个IP时,正常格式化
if (ips.length === 1) {
const ip = ips[0];
// 检查是否为IPv6地址
if (ip.includes(':') && !ip.startsWith('[')) {
// IPv6地址需要用方括号包裹
return `[${ip}]:${port}`;
} else {
// IPv4地址或已包含方括号的IPv6地址直接拼接
return `${ip}:${port}`;
}
}
// 多个IP时,显示第一个IP + 数量提示
const firstIp = ips[0];
let formattedFirstIp;
// 格式化第一个IP
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
formattedFirstIp = `[${firstIp}]`;
} else {
formattedFirstIp = firstIp;
}
return `${formattedFirstIp}:${port} 等${ips.length}个入口`;
},
// 获取完整的入口地址列表(用于title显示)
getFullInAddressList(ipString, port) {
if (!ipString || !port) return '无入口地址配置';
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length === 0) return '无入口地址配置';
if (ips.length === 1) {
// 单个IP时,直接返回格式化后的地址
return this.formatInAddress(ipString, port);
}
// 多个IP时在title中显示所有地址
const formattedAddresses = ips.map(ip => {
if (ip.includes(':') && !ip.startsWith('[')) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
});
return `入口地址列表 (${ips.length}个):\n${formattedAddresses.join('\n')}`;
},
// 获取复制用的入口地址(支持多IP)
getCopyInAddress(ipString, port) {
if (!ipString || !port) return '';
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length === 0) return '';
if (ips.length === 1) {
// 单个IP时,返回格式化后的地址
return this.formatInAddress(ipString, port);
}
// 多个IP时返回所有地址,用换行符分隔
const formattedAddresses = ips.map(ip => {
if (ip.includes(':') && !ip.startsWith('[')) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
});
return formattedAddresses.join('\n');
},
// 检查是否有多个IP
hasMultipleIps(ipString) {
if (!ipString) return false;
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
return ips.length > 1;
},
// 显示地址列表弹窗
showAddressDialog(ipString, port, title) {
if (!ipString || !port) return;
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
// 如果只有一个IP,直接复制,不显示弹窗
if (ips.length <= 1) {
this.copyToClipboard(this.formatInAddress(ipString, port), title);
return;
}
// 格式化地址列表
this.addressList = ips.map((ip, index) => {
let formattedAddress;
if (ip.includes(':') && !ip.startsWith('[')) {
formattedAddress = `[${ip}]:${port}`;
} else {
formattedAddress = `${ip}:${port}`;
}
return {
id: index,
ip: ip,
address: formattedAddress,
copying: false // 复制状态
};
});
this.addressDialogTitle = `${title}列表 (共${ips.length}个)`;
this.addressDialogVisible = true;
},
// 显示目标地址弹窗
showTargetAddressDialog(addressString, title) {
if (!addressString) return;
const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
// 如果只有一个地址,直接复制,不显示弹窗
if (addresses.length <= 1) {
this.copyToClipboard(addressString, title);
return;
}
// 格式化地址列表
this.addressList = addresses.map((address, index) => {
// 提取IP部分用于显示
let displayIp = address;
if (address.includes(':')) {
if (address.startsWith('[')) {
// IPv6格式:[2001:db8::1]:8080
const match = address.match(/^\[(.+)\]:/);
displayIp = match ? match[1] : address;
} else {
// IPv4格式:192.168.1.1:8080 或域名格式
const lastColonIndex = address.lastIndexOf(':');
displayIp = lastColonIndex > 0 ? address.substring(0, lastColonIndex) : address;
}
}
return {
id: index,
ip: displayIp,
address: address,
copying: false // 复制状态
};
});
this.addressDialogTitle = `${title}列表 (共${addresses.length}个)`;
this.addressDialogVisible = true;
},
// 格式化目标地址显示
formatTargetAddress(addressString) {
if (!addressString) return '';
const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length === 0) return '';
if (addresses.length === 1) return addresses[0];
// 多个地址时显示第一个地址 + 数量提示
return `${addresses[0]} 等${addresses.length}个目标`;
},
// 检查是否有多个目标地址
hasMultipleTargetAddresses(addressString) {
if (!addressString) return false;
const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
return addresses.length > 1;
},
// 获取地址数量(用于表单中策略选择显示控制)
getAddressCount(addressString) {
if (!addressString) return 0;
// 按行分割地址
const addresses = addressString.split('\n').map(addr => addr.trim()).filter(addr => addr);
return addresses.length;
},
// 复制单个地址
async copyAddress(addressItem) {
try {
this.$set(addressItem, 'copying', true);
await copyWithMessage(addressItem.address, '地址', this);
} catch (error) {
this.$message.error('复制失败');
} finally {
this.$set(addressItem, 'copying', false);
}
},
// 复制所有地址
async copyAllAddresses() {
if (this.addressList.length === 0) return;
const allAddresses = this.addressList.map(item => item.address).join('\n');
await copyWithMessage(allAddresses, '所有地址', this);
},
// 复制到剪贴板
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 '未知';
}
},
// 获取策略类型
getStrategyType(strategy) {
switch (strategy) {
case 'fifo':
return 'primary'; // 蓝色 - 主备模式
case 'round':
return 'success'; // 绿色 - 轮询模式
case 'rand':
return 'warning'; // 橙色 - 随机模式
default:
return 'info'; // 默认
}
},
// 获取策略文本
getStrategyText(strategy) {
switch (strategy) {
case 'fifo':
return '主备模式';
case 'round':
return '轮询模式';
case 'rand':
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: 16px;
}
/* 移动端优化 */
@media (max-width: 768px) {
.forward-container {
padding: 8px;
}
}
/* 页面头部 */
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
background: white;
padding: 12px 20px;
border-radius: 6px;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0;
display: flex;
align-items: center;
}
.page-title i {
margin-right: 6px;
color: #409eff;
}
.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: 8px;
}
.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: 6px;
margin: 15px;
}
.el-dialog__header {
padding: 12px 16px 8px;
}
.el-dialog__body {
padding: 8px 16px;
}
.el-dialog__footer {
padding: 8px 16px 12px;
}
.el-dialog__title {
font-size: 15px;
}
.el-form-item__label {
font-size: 13px;
}
.dialog-footer {
text-align: center;
}
.dialog-footer .el-button {
min-width: 70px;
}
}
.cards-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
padding: 0;
}
/* 移动端卡片容器 */
@media (max-width: 768px) {
.cards-container {
grid-template-columns: 1fr;
gap: 10px;
padding: 0;
width: 100%;
max-width: 100%;
overflow: hidden;
}
}
.forward-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
padding: 16px;
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
overflow: hidden; /* 防止内容撑大容器 */
}
/* 移动端卡片样式 */
@media (max-width: 768px) {
.forward-card {
padding: 12px;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.forward-card:hover {
transform: none;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
}
/* 桌面端悬停效果 */
@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: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
/* 移动端卡片头部 */
@media (max-width: 768px) {
.card-header {
margin-bottom: 10px;
padding-bottom: 6px;
flex-wrap: wrap;
gap: 8px;
}
}
.card-title {
font-size: 14px;
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: 13px;
}
.title-text {
max-width: calc(100% - 60px); /* 为操作按钮留出空间 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.card-title i {
margin-right: 6px;
color: #409EFF;
flex-shrink: 0;
font-size: 14px;
}
.title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.card-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* 移动端卡片操作 */
@media (max-width: 768px) {
.card-actions {
gap: 4px;
}
.card-actions .el-button--mini {
padding: 4px;
}
}
.card-body {
margin-top: 0;
overflow: hidden; /* 防止内容撑大容器 */
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.info-row:last-child {
margin-bottom: 0;
}
/* 移动端信息行 */
@media (max-width: 768px) {
.info-row {
margin-bottom: 8px;
flex-direction: column;
gap: 6px;
}
/* 在移动端保持一行显示的信息行 */
.info-row.inline-on-mobile {
flex-direction: row;
gap: 12px;
}
.info-row.inline-on-mobile .info-item {
flex: 1;
min-width: 0;
}
.info-row.inline-on-mobile .info-item:not(:last-child) {
margin-right: 8px;
margin-bottom: 0;
}
.info-row.inline-on-mobile .value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.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: 12px;
}
/* 移动端信息项 */
@media (max-width: 768px) {
.info-item:not(:last-child) {
margin-right: 0;
margin-bottom: 6px;
}
.info-item:last-child {
margin-bottom: 0;
}
}
.label {
font-size: 11px;
color: #909399;
margin-bottom: 2px;
font-weight: 500;
}
.value {
font-size: 13px;
color: #303133;
font-weight: 500;
}
/* 移动端字体优化 */
@media (max-width: 768px) {
.label {
font-size: 10px;
margin-bottom: 1px;
}
.value {
font-size: 12px;
}
}
/* 空状态使用统一组件,样式已内置 */
.flow-stats-mini {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* 移动端流量统计 */
@media (max-width: 768px) {
.flow-stats-mini {
gap: 4px;
}
}
.flow-stat-item {
flex: 1;
display: flex;
flex-direction: column;
min-width: 50px;
}
/* 移动端流量统计项 */
@media (max-width: 480px) {
.flow-stat-item {
min-width: 45px;
}
}
.flow-stat-label {
font-size: 11px;
color: #909399;
margin-bottom: 2px;
font-weight: 500;
}
.flow-stat-value {
font-size: 13px;
color: #303133;
font-weight: 500;
}
/* 移动端流量统计字体 */
@media (max-width: 768px) {
.flow-stat-label {
font-size: 10px;
margin-bottom: 1px;
}
.flow-stat-value {
font-size: 11px;
}
}
.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: 6px;
margin-bottom: 2px;
}
.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;
font-family: monospace;
color: #409EFF;
font-weight: 500;
}
.address-text.clickable {
cursor: pointer;
transition: background-color 0.2s;
}
.address-text.clickable:hover {
background-color: rgba(64, 158, 255, 0.1);
}
.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-row.inline-on-mobile,
.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%;
}
/* 移动端空状态样式已在统一组件中处理 */
/* 移动端form hint */
.form-hint {
font-size: 11px;
line-height: 1.4;
}
}
/* 移动端头部样式 */
@media (max-width: 768px) {
.header-bar {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 10px 15px;
margin-bottom: 10px;
}
.page-title {
font-size: 16px;
text-align: center;
}
}
/* 超小屏幕优化 */
@media (max-width: 480px) {
.forward-container {
padding: 6px;
}
.header-bar {
padding: 8px 12px;
margin-bottom: 8px;
}
.page-title {
font-size: 15px;
}
.forward-card {
padding: 10px;
}
.card-header {
margin-bottom: 8px;
}
.info-row {
margin-bottom: 6px;
}
/* 超小屏幕下调整间距 */
.info-row.inline-on-mobile {
gap: 8px;
}
.info-row.inline-on-mobile .info-item:not(:last-child) {
margin-right: 6px;
}
}
/* 地址列表弹窗样式 */
.address-list-dialog .el-dialog__body {
padding: 16px;
}
.address-list-container {
max-height: 60vh;
overflow-y: auto;
}
.address-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding: 10px 12px;
background: #f8f9fa;
border-radius: 6px;
border-left: 3px solid #409EFF;
}
.header-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #606266;
}
.header-info i {
color: #409EFF;
font-size: 16px;
}
.copy-all-btn {
border-radius: 6px;
font-weight: 500;
}
.address-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.address-list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: white;
border: 1px solid #e4e7ed;
border-radius: 6px;
transition: all 0.3s ease;
}
.address-list-item:hover {
border-color: #409EFF;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
.address-content {
flex: 1;
min-width: 0;
margin-right: 12px;
}
.ip-info {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.ip-info i {
color: #909399;
font-size: 14px;
}
.ip-text {
font-size: 12px;
color: #909399;
font-weight: 500;
}
.address-text-full {
font-family: monospace;
font-size: 14px;
font-weight: 600;
color: #409EFF;
word-break: break-all;
line-height: 1.4;
}
.copy-single-btn {
flex-shrink: 0;
color: #409EFF;
border: 1px solid #409EFF;
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
transition: all 0.3s ease;
}
.copy-single-btn:hover {
background-color: #409EFF;
color: white;
}
.copy-single-btn.is-loading {
background-color: rgba(64, 158, 255, 0.1);
}
/* 移动端地址列表优化 */
@media (max-width: 768px) {
.address-list-dialog .el-dialog__body {
padding: 12px;
}
.address-list-header {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 10px;
}
.header-info {
justify-content: center;
font-size: 12px;
}
.copy-all-btn {
align-self: center;
min-width: 100px;
}
.address-list-item {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 10px;
}
.address-content {
margin-right: 0;
margin-bottom: 6px;
}
.address-text-full {
font-size: 12px;
}
.copy-single-btn {
align-self: center;
min-width: 70px;
}
}
/* 超小屏幕优化 */
@media (max-width: 480px) {
.address-list-dialog .el-dialog__body {
padding: 8px;
}
.address-list-header {
padding: 8px;
}
.header-info {
font-size: 11px;
}
.address-list-item {
padding: 8px;
}
.address-text-full {
font-size: 11px;
}
.ip-text {
font-size: 10px;
}
}
</style>