mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-09 03:06:37 +08:00
修复v6 添加隧道类型选择
This commit is contained in:
+1
-3
@@ -1,8 +1,6 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.0
|
image: mysql:5.7
|
||||||
container_name: gost-mysql
|
container_name: gost-mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/go-gost/gost/traffic"
|
|
||||||
"log"
|
"log"
|
||||||
_ "net/http/pprof"
|
_ "net/http/pprof"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,6 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-gost/gost/traffic"
|
||||||
|
|
||||||
"github.com/go-gost/core/logger"
|
"github.com/go-gost/core/logger"
|
||||||
xlogger "github.com/go-gost/x/logger"
|
xlogger "github.com/go-gost/x/logger"
|
||||||
"github.com/judwhite/go-svc"
|
"github.com/judwhite/go-svc"
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ CREATE TABLE `tunnel` (
|
|||||||
`out_ip_end` int(10) NOT NULL,
|
`out_ip_end` int(10) NOT NULL,
|
||||||
`type` int(10) NOT NULL,
|
`type` int(10) NOT NULL,
|
||||||
`flow` int(10) NOT NULL,
|
`flow` int(10) NOT NULL,
|
||||||
|
`protocol` varchar(10) DEFAULT 'tls' COMMENT '协议类型: tls, tcp, mtls',
|
||||||
`created_time` bigint(20) NOT NULL,
|
`created_time` bigint(20) NOT NULL,
|
||||||
`updated_time` bigint(20) NOT NULL,
|
`updated_time` bigint(20) NOT NULL,
|
||||||
`status` int(10) NOT NULL
|
`status` int(10) NOT NULL
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ cat > "$INSTALL_DIR/gost.json" <<EOF
|
|||||||
"metadata": {
|
"metadata": {
|
||||||
"pathPrefix": "/api"
|
"pathPrefix": "/api"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"listener": {
|
||||||
|
"type": "tls"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -39,4 +39,7 @@ public class TunnelDto {
|
|||||||
|
|
||||||
@NotNull(message = "流量计算类型不能为空")
|
@NotNull(message = "流量计算类型不能为空")
|
||||||
private Integer flow;
|
private Integer flow;
|
||||||
|
|
||||||
|
// 协议类型(隧道转发时使用:tls、tcp、mtls),默认为tls
|
||||||
|
private String protocol;
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,8 @@ public class UserTunnelWithDetailDto {
|
|||||||
* 出站流量(字节)
|
* 出站流量(字节)
|
||||||
*/
|
*/
|
||||||
private Long outFlow;
|
private Long outFlow;
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
// * 入口IP
|
// * 入口IP
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class GostUtil {
|
|||||||
public static GostDto SaveConfig(String addr, String secret) {
|
public static GostDto SaveConfig(String addr, String secret) {
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
data.put("format", "json");
|
data.put("format", "json");
|
||||||
String url = "http://" + addr + "/api/config?format=json";
|
String url = "https://" + addr + "/api/config?format=json";
|
||||||
return HttpUtils.post(url, data, secret);
|
return HttpUtils.post(url, data, secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,9 +181,9 @@ public class GostUtil {
|
|||||||
return HttpUtils.post(url, data, secret);
|
return HttpUtils.post(url, data, secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static GostDto AddChains(String addr, String name, String remoteAddr, String secret) {
|
public static GostDto AddChains(String addr, String name, String remoteAddr, String secret, String protocol) {
|
||||||
JSONObject dialer = new JSONObject();
|
JSONObject dialer = new JSONObject();
|
||||||
dialer.put("type", "tls");
|
dialer.put("type", protocol);
|
||||||
|
|
||||||
JSONObject connector = new JSONObject();
|
JSONObject connector = new JSONObject();
|
||||||
connector.put("type", "relay");
|
connector.put("type", "relay");
|
||||||
@@ -212,9 +212,9 @@ public class GostUtil {
|
|||||||
return HttpUtils.post(url, data, secret);
|
return HttpUtils.post(url, data, secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static GostDto UpdateChains(String addr, String name, String remoteAddr, String secret) {
|
public static GostDto UpdateChains(String addr, String name, String remoteAddr, String secret, String protocol) {
|
||||||
JSONObject dialer = new JSONObject();
|
JSONObject dialer = new JSONObject();
|
||||||
dialer.put("type", "tls");
|
dialer.put("type", protocol);
|
||||||
|
|
||||||
JSONObject connector = new JSONObject();
|
JSONObject connector = new JSONObject();
|
||||||
connector.put("type", "relay");
|
connector.put("type", "relay");
|
||||||
@@ -249,7 +249,7 @@ public class GostUtil {
|
|||||||
return HttpUtils.delete(url, secret);
|
return HttpUtils.delete(url, secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static GostDto AddRemoteService(String addr, String name, Integer out_port, String remoteAddr, String secret) {
|
public static GostDto AddRemoteService(String addr, String name, Integer out_port, String remoteAddr, String secret, String protocol) {
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
data.put("name", name + "_tls");
|
data.put("name", name + "_tls");
|
||||||
data.put("addr", ":" + out_port);
|
data.put("addr", ":" + out_port);
|
||||||
@@ -257,7 +257,7 @@ public class GostUtil {
|
|||||||
handler.put("type", "relay");
|
handler.put("type", "relay");
|
||||||
data.put("handler", handler);
|
data.put("handler", handler);
|
||||||
JSONObject listener = new JSONObject();
|
JSONObject listener = new JSONObject();
|
||||||
listener.put("type", "tls");
|
listener.put("type", protocol);
|
||||||
data.put("listener", listener);
|
data.put("listener", listener);
|
||||||
JSONObject forwarder = new JSONObject();
|
JSONObject forwarder = new JSONObject();
|
||||||
JSONArray nodes = new JSONArray();
|
JSONArray nodes = new JSONArray();
|
||||||
@@ -388,6 +388,14 @@ public class GostUtil {
|
|||||||
* 构建API URL
|
* 构建API URL
|
||||||
*/
|
*/
|
||||||
private static String buildUrl(String addr, String endpoint) {
|
private static String buildUrl(String addr, String endpoint) {
|
||||||
return "http://" + addr + API_BASE_URL + endpoint;
|
// 如果是IPv6地址(包含多个冒号且不包含方括号),需要用方括号包裹IP部分
|
||||||
|
if (!addr.contains("[") && addr.indexOf(':') != addr.lastIndexOf(':')) {
|
||||||
|
// 这是IPv6地址,找到最后一个冒号(端口分隔符)
|
||||||
|
int lastColonIndex = addr.lastIndexOf(':');
|
||||||
|
String ipPart = addr.substring(0, lastColonIndex);
|
||||||
|
String portPart = addr.substring(lastColonIndex);
|
||||||
|
addr = "[" + ipPart + "]" + portPart;
|
||||||
|
}
|
||||||
|
return "https://" + addr + API_BASE_URL + endpoint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,17 +135,11 @@ public class HttpUtils implements ApplicationContextAware {
|
|||||||
/**
|
/**
|
||||||
* 创建带超时配置的RestTemplate
|
* 创建带超时配置的RestTemplate
|
||||||
*/
|
*/
|
||||||
|
@SneakyThrows
|
||||||
private static RestTemplate createRestTemplateWithTimeout() {
|
private static RestTemplate createRestTemplateWithTimeout() {
|
||||||
// 创建基础的RequestFactory
|
|
||||||
org.springframework.http.client.SimpleClientHttpRequestFactory factory =
|
|
||||||
new org.springframework.http.client.SimpleClientHttpRequestFactory();
|
|
||||||
|
|
||||||
// 设置连接超时和读取超时
|
|
||||||
factory.setConnectTimeout(TIMEOUT_MILLISECONDS);
|
|
||||||
factory.setReadTimeout(TIMEOUT_MILLISECONDS);
|
|
||||||
|
|
||||||
// 创建RestTemplate
|
// 创建RestTemplate
|
||||||
RestTemplate restTemplate = new RestTemplate(factory);
|
RestTemplate restTemplate = new RestTemplate(RestTemplateConfig.generateHttpRequestFactory());
|
||||||
restTemplate.setErrorHandler(new NoOpResponseErrorHandler());
|
restTemplate.setErrorHandler(new NoOpResponseErrorHandler());
|
||||||
|
|
||||||
return restTemplate;
|
return restTemplate;
|
||||||
@@ -203,6 +197,7 @@ public class HttpUtils implements ApplicationContextAware {
|
|||||||
asyncSaveConfig(url, secret);
|
asyncSaveConfig(url, secret);
|
||||||
return body;
|
return body;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
GostDto gostDto = new GostDto();
|
GostDto gostDto = new GostDto();
|
||||||
gostDto.setCode(500);
|
gostDto.setCode(500);
|
||||||
gostDto.setMsg("请求失败");
|
gostDto.setMsg("请求失败");
|
||||||
|
|||||||
@@ -27,11 +27,16 @@ public class RestTemplateConfig {
|
|||||||
return new RestTemplate(factory);
|
return new RestTemplate(factory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 10秒超时配置
|
||||||
|
private static final int TIMEOUT_SECONDS = 10;
|
||||||
|
private static final int TIMEOUT_MILLISECONDS = TIMEOUT_SECONDS * 1000;
|
||||||
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public ClientHttpRequestFactory simpleClientHttpRequestFactory(){
|
public ClientHttpRequestFactory simpleClientHttpRequestFactory(){
|
||||||
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
|
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
|
||||||
factory.setConnectTimeout(15000);
|
factory.setConnectTimeout(TIMEOUT_MILLISECONDS);
|
||||||
factory.setReadTimeout(5000);
|
factory.setReadTimeout(TIMEOUT_MILLISECONDS);
|
||||||
return factory;
|
return factory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,4 +72,9 @@ public class Tunnel extends BaseEntity {
|
|||||||
* 流量计算类型(1 单向计算上传。2 双向)
|
* 流量计算类型(1 单向计算上传。2 双向)
|
||||||
*/
|
*/
|
||||||
private int flow;
|
private int flow;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议类型(隧道转发时使用:tls、tcp、mtls)
|
||||||
|
*/
|
||||||
|
private String protocol;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -536,13 +536,13 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
|
|
||||||
// 隧道转发需要创建链和远程服务
|
// 隧道转发需要创建链和远程服务
|
||||||
if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) {
|
if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) {
|
||||||
R chainResult = createChainService(inNode, serviceName, tunnel.getOutIp(), forward.getOutPort());
|
R chainResult = createChainService(inNode, serviceName, tunnel.getOutIp(), forward.getOutPort(), tunnel.getProtocol());
|
||||||
if (chainResult.getCode() != 0) {
|
if (chainResult.getCode() != 0) {
|
||||||
updateForwardStatusToError(forward);
|
updateForwardStatusToError(forward);
|
||||||
return chainResult;
|
return chainResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
R remoteResult = createRemoteService(tunnel.getOutNodeId().intValue(), serviceName, forward);
|
R remoteResult = createRemoteService(tunnel.getOutNodeId().intValue(), serviceName, forward, tunnel.getProtocol());
|
||||||
if (remoteResult.getCode() != 0) {
|
if (remoteResult.getCode() != 0) {
|
||||||
updateForwardStatusToError(forward);
|
updateForwardStatusToError(forward);
|
||||||
return remoteResult;
|
return remoteResult;
|
||||||
@@ -568,13 +568,13 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
|
|
||||||
// 隧道转发需要更新链和远程服务
|
// 隧道转发需要更新链和远程服务
|
||||||
if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) {
|
if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) {
|
||||||
R chainResult = updateChainService(inNode, serviceName, tunnel.getOutIp(), forward.getOutPort());
|
R chainResult = updateChainService(inNode, serviceName, tunnel.getOutIp(), forward.getOutPort(), tunnel.getProtocol());
|
||||||
if (chainResult.getCode() != 0) {
|
if (chainResult.getCode() != 0) {
|
||||||
updateForwardStatusToError(forward);
|
updateForwardStatusToError(forward);
|
||||||
return chainResult;
|
return chainResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
R remoteResult = updateRemoteService(tunnel.getOutNodeId().intValue(), serviceName, forward);
|
R remoteResult = updateRemoteService(tunnel.getOutNodeId().intValue(), serviceName, forward, tunnel.getProtocol());
|
||||||
if (remoteResult.getCode() != 0) {
|
if (remoteResult.getCode() != 0) {
|
||||||
updateForwardStatusToError(forward);
|
updateForwardStatusToError(forward);
|
||||||
return remoteResult;
|
return remoteResult;
|
||||||
@@ -624,20 +624,20 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
/**
|
/**
|
||||||
* 创建链服务
|
* 创建链服务
|
||||||
*/
|
*/
|
||||||
private R createChainService(Node inNode, String serviceName, String outIp, Integer outPort) {
|
private R createChainService(Node inNode, String serviceName, String outIp, Integer outPort, String protocol) {
|
||||||
String remoteAddr = outIp + ":" + outPort;
|
String remoteAddr = outIp + ":" + outPort;
|
||||||
GostDto result = GostUtil.AddChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret());
|
GostDto result = GostUtil.AddChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret(), protocol);
|
||||||
return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg());
|
return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建远程服务
|
* 创建远程服务
|
||||||
*/
|
*/
|
||||||
private R createRemoteService(Integer outNodeId, String serviceName, Forward forward) {
|
private R createRemoteService(Integer outNodeId, String serviceName, Forward forward, String protocol) {
|
||||||
Node outNode = nodeService.getNodeById(outNodeId.longValue());
|
Node outNode = nodeService.getNodeById(outNodeId.longValue());
|
||||||
GostDto result = GostUtil.AddRemoteService(outNode.getIp() + ":" + outNode.getPort(),
|
GostDto result = GostUtil.AddRemoteService(outNode.getIp() + ":" + outNode.getPort(),
|
||||||
serviceName, forward.getOutPort(),
|
serviceName, forward.getOutPort(),
|
||||||
forward.getRemoteAddr(), outNode.getSecret());
|
forward.getRemoteAddr(), outNode.getSecret(), protocol);
|
||||||
return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg());
|
return isGostOperationSuccess(result) ? R.ok() : R.err(result.getMsg());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,13 +654,13 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
/**
|
/**
|
||||||
* 更新链服务
|
* 更新链服务
|
||||||
*/
|
*/
|
||||||
private R updateChainService(Node inNode, String serviceName, String outIp, Integer outPort) {
|
private R updateChainService(Node inNode, String serviceName, String outIp, Integer outPort, String protocol) {
|
||||||
|
|
||||||
// 创建新链
|
// 创建新链
|
||||||
String remoteAddr = outIp + ":" + outPort;
|
String remoteAddr = outIp + ":" + outPort;
|
||||||
GostDto createResult = GostUtil.UpdateChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret());
|
GostDto createResult = GostUtil.UpdateChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret(), protocol);
|
||||||
if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) {
|
if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) {
|
||||||
createResult = GostUtil.AddChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret());
|
createResult = GostUtil.AddChains(inNode.getIp() + ":" + inNode.getPort(), serviceName, remoteAddr, inNode.getSecret(), protocol);
|
||||||
}
|
}
|
||||||
return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg());
|
return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg());
|
||||||
}
|
}
|
||||||
@@ -668,7 +668,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
/**
|
/**
|
||||||
* 更新远程服务
|
* 更新远程服务
|
||||||
*/
|
*/
|
||||||
private R updateRemoteService(Integer outNodeId, String serviceName, Forward forward) {
|
private R updateRemoteService(Integer outNodeId, String serviceName, Forward forward, String protocol) {
|
||||||
Node outNode = nodeService.getNodeById(outNodeId.longValue());
|
Node outNode = nodeService.getNodeById(outNodeId.longValue());
|
||||||
// 创建新远程服务
|
// 创建新远程服务
|
||||||
GostDto createResult = GostUtil.UpdateRemoteService(outNode.getIp() + ":" + outNode.getPort(),
|
GostDto createResult = GostUtil.UpdateRemoteService(outNode.getIp() + ":" + outNode.getPort(),
|
||||||
@@ -677,7 +677,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) {
|
if (createResult.getMsg().contains(GOST_NOT_FOUND_MSG)) {
|
||||||
createResult = GostUtil.AddRemoteService(outNode.getIp() + ":" + outNode.getPort(),
|
createResult = GostUtil.AddRemoteService(outNode.getIp() + ":" + outNode.getPort(),
|
||||||
serviceName, forward.getOutPort(),
|
serviceName, forward.getOutPort(),
|
||||||
forward.getRemoteAddr(), outNode.getSecret());
|
forward.getRemoteAddr(), outNode.getSecret(),protocol);
|
||||||
}
|
}
|
||||||
return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg());
|
return isGostOperationSuccess(createResult) ? R.ok() : R.err(createResult.getMsg());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,16 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
// 设置流量计算类型
|
// 设置流量计算类型
|
||||||
tunnel.setFlow(tunnelDto.getFlow());
|
tunnel.setFlow(tunnelDto.getFlow());
|
||||||
|
|
||||||
|
// 设置协议类型(仅隧道转发需要)
|
||||||
|
if (tunnelDto.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) {
|
||||||
|
// 隧道转发时,设置协议类型,默认为tls
|
||||||
|
String protocol = StrUtil.isNotBlank(tunnelDto.getProtocol()) ? tunnelDto.getProtocol() : "tls";
|
||||||
|
tunnel.setProtocol(protocol);
|
||||||
|
} else {
|
||||||
|
// 端口转发时,协议类型为null
|
||||||
|
tunnel.setProtocol(null);
|
||||||
|
}
|
||||||
|
|
||||||
return tunnel;
|
return tunnel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +320,13 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
return R.err(ERROR_SAME_NODE_NOT_ALLOWED);
|
return R.err(ERROR_SAME_NODE_NOT_ALLOWED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证协议类型
|
||||||
|
String protocol = tunnelDto.getProtocol();
|
||||||
|
if (StrUtil.isNotBlank(protocol) &&
|
||||||
|
!protocol.equals("tls") && !protocol.equals("tcp") && !protocol.equals("mtls")) {
|
||||||
|
return R.err("协议类型只能为tls、tcp或mtls");
|
||||||
|
}
|
||||||
|
|
||||||
// 验证出口节点是否存在
|
// 验证出口节点是否存在
|
||||||
Node outNode = nodeService.getById(tunnelDto.getOutNodeId());
|
Node outNode = nodeService.getById(tunnelDto.getOutNodeId());
|
||||||
if (outNode == null) {
|
if (outNode == null) {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
t.out_ip AS outIp,
|
t.out_ip AS outIp,
|
||||||
t.out_ip_sta AS outIpSta,
|
t.out_ip_sta AS outIpSta,
|
||||||
t.out_ip_end AS outIpEnd,
|
t.out_ip_end AS outIpEnd,
|
||||||
t.type
|
t.type,
|
||||||
|
t.protocol
|
||||||
FROM
|
FROM
|
||||||
forward f
|
forward f
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
@@ -57,7 +58,8 @@
|
|||||||
t.out_ip AS outIp,
|
t.out_ip AS outIp,
|
||||||
t.out_ip_sta AS outIpSta,
|
t.out_ip_sta AS outIpSta,
|
||||||
t.out_ip_end AS outIpEnd,
|
t.out_ip_end AS outIpEnd,
|
||||||
t.type
|
t.type,
|
||||||
|
t.protocol
|
||||||
FROM
|
FROM
|
||||||
forward f
|
forward f
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
ut.flow_reset_time as flowResetTime,
|
ut.flow_reset_time as flowResetTime,
|
||||||
ut.exp_time as expTime,
|
ut.exp_time as expTime,
|
||||||
ut.speed_id as speedId,
|
ut.speed_id as speedId,
|
||||||
|
ut.status as status,
|
||||||
t.name as tunnelName,
|
t.name as tunnelName,
|
||||||
t.flow as tunnelFlow,
|
t.flow as tunnelFlow,
|
||||||
t.in_ip as inIp,
|
t.in_ip as inIp,
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
t.out_ip_sta as outIpSta,
|
t.out_ip_sta as outIpSta,
|
||||||
t.out_ip_end as outIpEnd,
|
t.out_ip_end as outIpEnd,
|
||||||
t.type,
|
t.type,
|
||||||
|
t.protocol,
|
||||||
sl.name as speedLimitName,
|
sl.name as speedLimitName,
|
||||||
sl.speed
|
sl.speed
|
||||||
FROM user_tunnel ut
|
FROM user_tunnel ut
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -92,13 +92,48 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<div class="info-item">
|
<div class="info-item full-width">
|
||||||
<span class="label">入口地址:</span>
|
<div class="address-item">
|
||||||
<span class="value">{{ forward.inIp}}:{{ forward.inPort }}</span>
|
<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-item">
|
</div>
|
||||||
<span class="label">目标地址:</span>
|
|
||||||
<span class="value">{{ forward.remoteAddr}}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -216,6 +251,7 @@ import {
|
|||||||
pauseForwardService,
|
pauseForwardService,
|
||||||
resumeForwardService
|
resumeForwardService
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { copyWithMessage } from "@/utils/clipboard";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Forward",
|
name: "Forward",
|
||||||
@@ -570,6 +606,25 @@ export default {
|
|||||||
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
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) {
|
getStatusType(status) {
|
||||||
switch (parseInt(status)) {
|
switch (parseInt(status)) {
|
||||||
@@ -806,10 +861,12 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0; /* 确保flex子元素可以收缩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item.full-width {
|
.info-item.full-width {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0; /* 确保flex子元素可以收缩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item:not(:last-child) {
|
.info-item:not(:last-child) {
|
||||||
@@ -900,5 +957,80 @@ export default {
|
|||||||
color: #c0c4cc !important;
|
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>
|
</style>
|
||||||
@@ -834,6 +834,10 @@ export default {
|
|||||||
background: #ebeef5;
|
background: #ebeef5;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap; /* 不换行 */
|
||||||
|
overflow: hidden; /* 超出隐藏 */
|
||||||
|
text-overflow: ellipsis; /* 超出显示省略号 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-source {
|
.address-source {
|
||||||
|
|||||||
@@ -44,6 +44,11 @@
|
|||||||
<div class="tunnel-badges">
|
<div class="tunnel-badges">
|
||||||
<el-tag v-if="tunnel.type === 1" type="primary" size="mini">端口转发</el-tag>
|
<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-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-if="tunnel.flow === 1" type="success" size="mini">单向计算</el-tag>
|
||||||
<el-tag v-else type="info" size="mini">双向计算</el-tag>
|
<el-tag v-else type="info" size="mini">双向计算</el-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,6 +251,17 @@
|
|||||||
<template v-if="tunnelForm.type === 2">
|
<template v-if="tunnelForm.type === 2">
|
||||||
<el-divider content-position="left">出口配置</el-divider>
|
<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-form-item label="出口节点" prop="outNodeId">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="tunnelForm.outNodeId"
|
v-model="tunnelForm.outNodeId"
|
||||||
@@ -344,7 +360,8 @@ export default {
|
|||||||
outIpEnd: 65535,
|
outIpEnd: 65535,
|
||||||
type: 1,
|
type: 1,
|
||||||
flow: 1, // 默认单向计算
|
flow: 1, // 默认单向计算
|
||||||
status: 1 // 默认启用
|
status: 1, // 默认启用
|
||||||
|
protocol: 'tls'
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
name: [
|
name: [
|
||||||
@@ -376,6 +393,9 @@ export default {
|
|||||||
],
|
],
|
||||||
outIpEnd: [
|
outIpEnd: [
|
||||||
{ required: false, message: '请输入结束端口', trigger: 'blur' }
|
{ 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.outNodeId[0].required = true;
|
||||||
this.rules.outIpSta[0].required = true;
|
this.rules.outIpSta[0].required = true;
|
||||||
this.rules.outIpEnd[0].required = true;
|
this.rules.outIpEnd[0].required = true;
|
||||||
|
this.rules.protocol[0].required = true;
|
||||||
} else {
|
} else {
|
||||||
// 端口转发时,出口字段不必填
|
// 端口转发时,出口字段不必填
|
||||||
this.rules.outNodeId[0].required = false;
|
this.rules.outNodeId[0].required = false;
|
||||||
this.rules.outIpSta[0].required = false;
|
this.rules.outIpSta[0].required = false;
|
||||||
this.rules.outIpEnd[0].required = false;
|
this.rules.outIpEnd[0].required = false;
|
||||||
|
this.rules.protocol[0].required = false;
|
||||||
|
|
||||||
// 清空出口字段
|
// 清空出口字段
|
||||||
this.tunnelForm.outNodeId = null;
|
this.tunnelForm.outNodeId = null;
|
||||||
this.tunnelForm.outIpSta = null;
|
this.tunnelForm.outIpSta = null;
|
||||||
this.tunnelForm.outIpEnd = null;
|
this.tunnelForm.outIpEnd = null;
|
||||||
|
this.tunnelForm.protocol = 'tls'; // 重置为默认值
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -547,7 +570,8 @@ export default {
|
|||||||
outIpEnd: 65535,
|
outIpEnd: 65535,
|
||||||
type: 1,
|
type: 1,
|
||||||
flow: 1, // 默认单向计算
|
flow: 1, // 默认单向计算
|
||||||
status: 1 // 默认启用
|
status: 1, // 默认启用
|
||||||
|
protocol: 'tls'
|
||||||
};
|
};
|
||||||
if (this.$refs.tunnelForm) {
|
if (this.$refs.tunnelForm) {
|
||||||
this.$refs.tunnelForm.clearValidate();
|
this.$refs.tunnelForm.clearValidate();
|
||||||
|
|||||||
@@ -131,7 +131,15 @@
|
|||||||
<span>主机IP</span>
|
<span>主机IP</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-content">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +221,7 @@
|
|||||||
<el-form-item v-if="isEdit" label="节点IP" prop="ip">
|
<el-form-item v-if="isEdit" label="节点IP" prop="ip">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="nodeForm.ip"
|
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
|
clearable
|
||||||
></el-input>
|
></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -226,15 +234,7 @@
|
|||||||
show-icon
|
show-icon
|
||||||
style="margin-bottom: 20px;">
|
style="margin-bottom: 20px;">
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="isEdit"
|
|
||||||
title="编辑模式下只能修改节点名称和IP,端口在创建后不可更改"
|
|
||||||
type="warning"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
style="margin-bottom: 20px;">
|
|
||||||
</el-alert>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<span slot="footer" class="dialog-footer">
|
<span slot="footer" class="dialog-footer">
|
||||||
@@ -253,6 +253,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {createNode, updateNode, deleteNode, getNodeList, getNodeInstallCommand} from "@/api";
|
import {createNode, updateNode, deleteNode, getNodeList, getNodeInstallCommand} from "@/api";
|
||||||
|
import { copyWithMessage, copyWithFallback } from "@/utils/clipboard";
|
||||||
import VChart from 'vue-echarts';
|
import VChart from 'vue-echarts';
|
||||||
import { use } from 'echarts/core';
|
import { use } from 'echarts/core';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
@@ -313,13 +314,16 @@ export default {
|
|||||||
// IPv4格式验证
|
// 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]?)$/;
|
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}))|:)))$/;
|
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();
|
callback();
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
callback();
|
callback();
|
||||||
@@ -452,9 +456,9 @@ export default {
|
|||||||
this.$set(node, 'copyLoading', false);
|
this.$set(node, 'copyLoading', false);
|
||||||
|
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
// 将命令复制到剪贴板
|
// 对于异步获取的内容,使用带降级方案的复制方法
|
||||||
this.copyToClipboard(res.data);
|
// 这样在Safari中如果自动复制失败,会显示手动复制对话框
|
||||||
this.$message.success('安装命令已复制到剪贴板');
|
copyWithFallback(res.data, '安装命令', this);
|
||||||
} else {
|
} else {
|
||||||
this.$message.error(res.msg || '获取安装命令失败');
|
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() {
|
handleSubmit() {
|
||||||
@@ -1066,7 +1044,9 @@ export default {
|
|||||||
.traffic-stats {
|
.traffic-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.traffic-stats.offline-stats {
|
.traffic-stats.offline-stats {
|
||||||
@@ -1094,6 +1074,14 @@ export default {
|
|||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px;
|
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 {
|
.stat-header {
|
||||||
@@ -1161,6 +1149,12 @@ export default {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
font-family: monospace;
|
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;
|
color: #007bff;
|
||||||
font-weight: 500;
|
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>
|
</style>
|
||||||
Reference in New Issue
Block a user