修复v6 添加隧道类型选择

This commit is contained in:
qaq
2025-06-18 11:13:41 +08:00
parent d56c94fd43
commit 8ddd65a58c
19 changed files with 568 additions and 88 deletions
+263
View File
@@ -0,0 +1,263 @@
/**
* 剪贴板工具类
* 兼容现代浏览器和旧版浏览器的复制功能
*/
/**
* 检测是否为Safari浏览器
* @returns {boolean}
*/
const isSafari = () => {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
};
/**
* Safari专用的复制方法
* @param {string} text - 要复制的文本
* @returns {Promise<boolean>}
*/
const safariCopy = (text) => {
return new Promise((resolve, reject) => {
const textArea = document.createElement('textarea');
textArea.value = text;
// Safari需要元素可见且可聚焦
textArea.style.position = 'absolute';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
textArea.style.fontSize = '12pt';
textArea.style.border = '0';
textArea.style.padding = '0';
textArea.style.margin = '0';
textArea.style.outline = 'none';
textArea.style.boxShadow = 'none';
textArea.style.background = 'transparent';
document.body.appendChild(textArea);
// Safari需要先聚焦
textArea.focus();
textArea.setSelectionRange(0, text.length);
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
resolve(true);
} else {
reject(new Error('Safari execCommand 复制失败'));
}
} catch (error) {
document.body.removeChild(textArea);
reject(error);
}
});
};
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
* @param {string} label - 复制内容的标签,用于提示消息(可选)
* @returns {Promise<boolean>} - 返回复制是否成功
*/
export const copyToClipboard = async (text, label = '内容') => {
if (!text) {
throw new Error('复制内容不能为空');
}
try {
// Safari浏览器特殊处理
if (isSafari()) {
// 优先尝试现代API,但Safari对安全上下文要求严格
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (clipboardError) {
console.warn('Safari Clipboard API失败,降级到execCommand:', clipboardError);
// 降级到Safari专用方法
await safariCopy(text);
return true;
}
} else {
// 直接使用Safari专用方法
await safariCopy(text);
return true;
}
}
// 方法1: 使用现代浏览器的 Clipboard API(非Safari浏览器首选)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
// 方法2: 使用 document.execCommand(通用兼容性方案)
const textArea = document.createElement('textarea');
textArea.value = text;
// 设置样式,使其不可见且不影响页面布局
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
textArea.style.opacity = '0';
textArea.style.pointerEvents = 'none';
textArea.style.zIndex = '-1';
// 添加到 DOM
document.body.appendChild(textArea);
// 选择文本
textArea.focus();
textArea.select();
// 兼容iOS设备的选择
if (navigator.userAgent.match(/ipad|iphone/i)) {
textArea.setSelectionRange(0, text.length);
}
// 尝试复制
const successful = document.execCommand('copy');
// 清理 DOM
document.body.removeChild(textArea);
if (successful) {
return true;
} else {
throw new Error('document.execCommand 复制失败');
}
} catch (error) {
console.error('复制失败:', error);
throw new Error(`复制${label}失败`);
}
};
/**
* 复制文本到剪贴板(带成功提示)
* 适用于 Vue 组件中使用,会显示 Element UI 的消息提示
* @param {string} text - 要复制的文本
* @param {string} label - 复制内容的标签
* @param {object} vm - Vue 组件实例(用于调用 $message)
* @returns {Promise<void>}
*/
export const copyWithMessage = async (text, label = '内容', vm) => {
try {
await copyToClipboard(text, label);
if (vm && vm.$message) {
vm.$message.success(`${label}已复制到剪贴板`);
}
} catch (error) {
console.error('复制失败详情:', error);
// 为Safari提供更详细的错误信息
let errorMessage = error.message || `复制${label}失败,请手动复制`;
if (isSafari() && !window.isSecureContext) {
errorMessage = `复制失败:Safari需要HTTPS环境。请手动复制${label}`;
} else if (isSafari() && error.message.includes('Safari')) {
errorMessage = `Safari复制失败,请手动选择并复制${label}`;
}
if (vm && vm.$message) {
vm.$message.error(errorMessage);
} else {
console.error(errorMessage);
}
}
};
/**
* 检查浏览器是否支持剪贴板操作
* @returns {object} - 返回支持的功能信息
*/
export const getClipboardSupport = () => {
const hasClipboardAPI = !!(navigator.clipboard && window.isSecureContext);
const hasExecCommand = !!document.execCommand;
const browserInfo = {
isSafari: isSafari(),
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
isSecureContext: window.isSecureContext
};
return {
modern: hasClipboardAPI, // 是否支持现代 Clipboard API
legacy: hasExecCommand, // 是否支持 execCommand
supported: hasClipboardAPI || hasExecCommand, // 是否支持任何复制方法
browser: browserInfo, // 浏览器信息
recommendedMethod: browserInfo.isSafari ? 'safari-optimized' :
hasClipboardAPI ? 'clipboard-api' : 'exec-command'
};
};
/**
* 显示手动复制提示(当自动复制失败时使用)
* @param {string} text - 要复制的文本
* @param {string} label - 复制内容的标签
* @param {object} vm - Vue 组件实例
*/
export const showManualCopyDialog = (text, label, vm) => {
if (vm && vm.$alert) {
const isSafariBrowser = isSafari();
const message = isSafariBrowser
? `Safari浏览器限制,无法自动复制。请手动选择并复制以下${label}:\n\n${text}`
: `请手动复制以下${label}:\n\n${text}`;
vm.$alert(
message,
isSafariBrowser ? 'Safari复制提示' : '复制提示',
{
confirmButtonText: '已复制',
type: 'info',
dangerouslyUseHTMLString: false,
customClass: 'copy-dialog'
}
);
}
};
/**
* 带降级方案的复制方法
* 如果自动复制失败,会显示手动复制对话框
* @param {string} text - 要复制的文本
* @param {string} label - 复制内容的标签
* @param {object} vm - Vue 组件实例
* @returns {Promise<void>}
*/
export const copyWithFallback = async (text, label = '内容', vm) => {
// 如果是Safari并且不在安全上下文中,直接显示手动复制对话框
if (isSafari() && !window.isSecureContext) {
console.warn('Safari非HTTPS环境,直接使用手动复制');
showManualCopyDialog(text, label, vm);
return;
}
try {
await copyToClipboard(text, label);
if (vm && vm.$message) {
vm.$message.success(`${label}已复制到剪贴板`);
}
} catch (error) {
console.error('自动复制失败,显示手动复制对话框:', error);
// 如果是Safari或自动复制失败,显示手动复制对话框
if (isSafari() || error.message.includes('失败')) {
showManualCopyDialog(text, label, vm);
} else {
// 其他错误正常提示
if (vm && vm.$message) {
vm.$message.error(error.message || `复制${label}失败`);
}
}
}
};
export default {
copyToClipboard,
copyWithMessage,
copyWithFallback,
showManualCopyDialog,
getClipboardSupport
};
+138 -6
View File
@@ -92,13 +92,48 @@
</div>
<div class="info-row">
<div class="info-item">
<span class="label">入口地址:</span>
<span class="value">{{ forward.inIp}}:{{ forward.inPort }}</span>
<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 class="info-item">
<span class="label">目标地址:</span>
<span class="value">{{ forward.remoteAddr}}</span>
</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>
@@ -216,6 +251,7 @@ import {
pauseForwardService,
resumeForwardService
} from "@/api";
import { copyWithMessage } from "@/utils/clipboard";
export default {
name: "Forward",
@@ -570,6 +606,25 @@ export default {
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)) {
@@ -806,10 +861,12 @@ export default {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; /* 确保flex子元素可以收缩 */
}
.info-item.full-width {
flex: 1;
min-width: 0; /* 确保flex子元素可以收缩 */
}
.info-item:not(:last-child) {
@@ -900,5 +957,80 @@ export default {
color: #c0c4cc !important;
}
/* 地址项样式 */
.address-item {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0; /* 确保flex子元素可以收缩 */
}
.label-with-copy {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.address-value {
width: 100%;
min-width: 0; /* 确保flex子元素可以收缩 */
}
.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;
}
/* 复制对话框样式 */
::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;
}
</style>
+4
View File
@@ -834,6 +834,10 @@ export default {
background: #ebeef5;
border-radius: 4px;
min-width: 120px;
width: 100%;
white-space: nowrap; /* 不换行 */
overflow: hidden; /* 超出隐藏 */
text-overflow: ellipsis; /* 超出显示省略号 */
}
.address-source {
+26 -2
View File
@@ -44,6 +44,11 @@
<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.type === 2 && tunnel.protocol"
:type="tunnel.protocol === 'tls' ? 'success' : tunnel.protocol === 'mtls' ? 'danger' : 'info'"
size="mini">
{{ tunnel.protocol.toUpperCase() }}
</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>
@@ -246,6 +251,17 @@
<template v-if="tunnelForm.type === 2">
<el-divider content-position="left">出口配置</el-divider>
<el-form-item label="协议类型" prop="protocol">
<el-radio-group v-model="tunnelForm.protocol">
<el-radio label="tls">TLS</el-radio>
<el-radio label="tcp">TCP</el-radio>
<el-radio label="mtls">mTLS</el-radio>
</el-radio-group>
<div class="form-hint">
TLS:传输层安全协议;TCP:传输控制协议;mTLS:双向TLS认证
</div>
</el-form-item>
<el-form-item label="出口节点" prop="outNodeId">
<el-select
v-model="tunnelForm.outNodeId"
@@ -344,7 +360,8 @@ export default {
outIpEnd: 65535,
type: 1,
flow: 1, // 默认单向计算
status: 1 // 默认启用
status: 1, // 默认启用
protocol: 'tls'
},
rules: {
name: [
@@ -376,6 +393,9 @@ export default {
],
outIpEnd: [
{ required: false, message: '请输入结束端口', trigger: 'blur' }
],
protocol: [
{ required: false, message: '请选择协议类型', trigger: 'change' }
]
}
};
@@ -475,16 +495,19 @@ export default {
this.rules.outNodeId[0].required = true;
this.rules.outIpSta[0].required = true;
this.rules.outIpEnd[0].required = true;
this.rules.protocol[0].required = true;
} else {
// 端口转发时,出口字段不必填
this.rules.outNodeId[0].required = false;
this.rules.outIpSta[0].required = false;
this.rules.outIpEnd[0].required = false;
this.rules.protocol[0].required = false;
// 清空出口字段
this.tunnelForm.outNodeId = null;
this.tunnelForm.outIpSta = null;
this.tunnelForm.outIpEnd = null;
this.tunnelForm.protocol = 'tls'; // 重置为默认值
}
},
@@ -547,7 +570,8 @@ export default {
outIpEnd: 65535,
type: 1,
flow: 1, // 默认单向计算
status: 1 // 默认启用
status: 1, // 默认启用
protocol: 'tls'
};
if (this.$refs.tunnelForm) {
this.$refs.tunnelForm.clearValidate();
+58 -43
View File
@@ -131,7 +131,15 @@
<span>主机IP</span>
</div>
<div class="stat-content">
<span class="ip-value">{{ node.connectionStatus === 'online' ? (node.ip || '-') : '-' }}</span>
<el-tooltip
:content="node.connectionStatus === 'online' ? (node.ip || '-') : '-'"
placement="top"
:disabled="!node.ip || node.ip === '-'"
>
<span class="ip-value">
{{ node.connectionStatus === 'online' ? (node.ip || '-') : '-' }}
</span>
</el-tooltip>
</div>
</div>
</div>
@@ -213,7 +221,7 @@
<el-form-item v-if="isEdit" label="节点IP" prop="ip">
<el-input
v-model="nodeForm.ip"
placeholder="请输入节点IP地址,如: 192.168.1.1 或 2001:db8::1"
placeholder="请输入节点IP地址或域名,如: 192.168.1.1、2001:db8::1 或 example.com"
clearable
></el-input>
</el-form-item>
@@ -226,15 +234,7 @@
show-icon
style="margin-bottom: 20px;">
</el-alert>
<el-alert
v-if="isEdit"
title="编辑模式下只能修改节点名称和IP,端口在创建后不可更改"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 20px;">
</el-alert>
</el-form>
<span slot="footer" class="dialog-footer">
@@ -253,6 +253,7 @@
<script>
import {createNode, updateNode, deleteNode, getNodeList, getNodeInstallCommand} from "@/api";
import { copyWithMessage, copyWithFallback } from "@/utils/clipboard";
import VChart from 'vue-echarts';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
@@ -313,13 +314,16 @@ export default {
// IPv4格式验证
const ipv4Regex = /^(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]?)$/;
// IPv6格式验证(完整版,支持各种压缩格式)
// IPv6格式验证(完整版,支持各种压缩格式,不需要方括号)
const ipv6Regex = /^((([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}))|:)))$/;
if (ipv4Regex.test(value) || ipv6Regex.test(value)) {
// 域名格式验证
const domainRegex = /^[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])?)*$/;
if (ipv4Regex.test(value) || ipv6Regex.test(value) || domainRegex.test(value)) {
callback();
} else {
callback(new Error('请输入有效的IP地址(支持IPv4和IPv6格式,如: 192.168.1.1 或 2001:db8::1)'));
callback(new Error('请输入有效的IP地址或域名(支持IPv4、IPv6和域名格式,如: 192.168.1.1、2001:db8::1 或 example.com)'));
}
} else {
callback();
@@ -452,9 +456,9 @@ export default {
this.$set(node, 'copyLoading', false);
if (res.code === 0 && res.data) {
// 将命令复制到剪贴板
this.copyToClipboard(res.data);
this.$message.success('安装命令已复制到剪贴板');
// 对于异步获取的内容,使用带降级方案的复制方法
// 这样在Safari中如果自动复制失败,会显示手动复制对话框
copyWithFallback(res.data, '安装命令', this);
} else {
this.$message.error(res.msg || '获取安装命令失败');
}
@@ -464,32 +468,6 @@ export default {
});
},
// 复制文本到剪贴板
copyToClipboard(text) {
// 创建一个临时的文本域元素
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
// 执行复制命令
document.execCommand('copy');
} catch (err) {
// 如果复制失败,尝试使用现代API
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
}
}
// 清理临时元素
document.body.removeChild(textArea);
},
// 提交表单
handleSubmit() {
@@ -1066,7 +1044,9 @@ export default {
.traffic-stats {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 10px;
width: 100%;
}
.traffic-stats.offline-stats {
@@ -1094,6 +1074,14 @@ export default {
background: #fafafa;
border-radius: 6px;
padding: 10px;
width: 100%;
box-sizing: border-box;
min-height: 60px;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0; /* 防止内容撑开容器 */
overflow: hidden; /* 防止内容溢出 */
}
.stat-header {
@@ -1161,6 +1149,12 @@ export default {
font-weight: 600;
color: #303133;
font-family: monospace;
display: block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
/* 空状态 */
@@ -1293,4 +1287,25 @@ export default {
color: #007bff;
font-weight: 500;
}
/* 复制对话框样式 */
::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;
}
</style>