mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-05 01:26:37 +08:00
修复显示问题
This commit is contained in:
@@ -565,6 +565,8 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
*/
|
*/
|
||||||
private Forward createForwardEntity(ForwardDto forwardDto, UserInfo currentUser, PortAllocation portAllocation) {
|
private Forward createForwardEntity(ForwardDto forwardDto, UserInfo currentUser, PortAllocation portAllocation) {
|
||||||
Forward forward = new Forward();
|
Forward forward = new Forward();
|
||||||
|
// 先复制DTO的属性,再设置其他属性,避免被覆盖
|
||||||
|
BeanUtils.copyProperties(forwardDto, forward);
|
||||||
forward.setStatus(FORWARD_STATUS_ACTIVE);
|
forward.setStatus(FORWARD_STATUS_ACTIVE);
|
||||||
forward.setInPort(portAllocation.getInPort());
|
forward.setInPort(portAllocation.getInPort());
|
||||||
forward.setOutPort(portAllocation.getOutPort());
|
forward.setOutPort(portAllocation.getOutPort());
|
||||||
@@ -572,7 +574,6 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
forward.setUserName(currentUser.getUserName());
|
forward.setUserName(currentUser.getUserName());
|
||||||
forward.setCreatedTime(System.currentTimeMillis());
|
forward.setCreatedTime(System.currentTimeMillis());
|
||||||
forward.setUpdatedTime(System.currentTimeMillis());
|
forward.setUpdatedTime(System.currentTimeMillis());
|
||||||
BeanUtils.copyProperties(forwardDto, forward);
|
|
||||||
return forward;
|
return forward;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
Tunnel tunnel = buildTunnelEntity(tunnelDto, inNodeValidation.getNode());
|
Tunnel tunnel = buildTunnelEntity(tunnelDto, inNodeValidation.getNode());
|
||||||
|
|
||||||
// 5. 根据隧道类型设置出口参数
|
// 5. 根据隧道类型设置出口参数
|
||||||
R outNodeSetupResult = setupOutNodeParameters(tunnel, tunnelDto);
|
R outNodeSetupResult = setupOutNodeParameters(tunnel, tunnelDto, inNodeValidation.getNode().getServerIp());
|
||||||
if (outNodeSetupResult.getCode() != 0) {
|
if (outNodeSetupResult.getCode() != 0) {
|
||||||
return outNodeSetupResult;
|
return outNodeSetupResult;
|
||||||
}
|
}
|
||||||
@@ -408,7 +408,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
|
|
||||||
// 设置入口节点信息
|
// 设置入口节点信息
|
||||||
tunnel.setInNodeId(tunnelDto.getInNodeId());
|
tunnel.setInNodeId(tunnelDto.getInNodeId());
|
||||||
tunnel.setInIp(inNode.getServerIp());
|
tunnel.setInIp(inNode.getIp());
|
||||||
|
|
||||||
// 设置流量计算类型
|
// 设置流量计算类型
|
||||||
tunnel.setFlow(tunnelDto.getFlow());
|
tunnel.setFlow(tunnelDto.getFlow());
|
||||||
@@ -439,10 +439,10 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
* @param tunnelDto 隧道创建DTO
|
* @param tunnelDto 隧道创建DTO
|
||||||
* @return 设置结果响应
|
* @return 设置结果响应
|
||||||
*/
|
*/
|
||||||
private R setupOutNodeParameters(Tunnel tunnel, TunnelDto tunnelDto) {
|
private R setupOutNodeParameters(Tunnel tunnel, TunnelDto tunnelDto, String server_ip) {
|
||||||
if (tunnelDto.getType() == TUNNEL_TYPE_PORT_FORWARD) {
|
if (tunnelDto.getType() == TUNNEL_TYPE_PORT_FORWARD) {
|
||||||
// 端口转发:出口参数使用入口参数
|
// 端口转发:出口参数使用入口参数
|
||||||
return setupPortForwardOutParameters(tunnel, tunnelDto);
|
return setupPortForwardOutParameters(tunnel, tunnelDto, server_ip);
|
||||||
} else {
|
} else {
|
||||||
// 隧道转发:需要验证出口参数
|
// 隧道转发:需要验证出口参数
|
||||||
return setupTunnelForwardOutParameters(tunnel, tunnelDto);
|
return setupTunnelForwardOutParameters(tunnel, tunnelDto);
|
||||||
@@ -456,9 +456,9 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
* @param tunnelDto 隧道创建DTO
|
* @param tunnelDto 隧道创建DTO
|
||||||
* @return 设置结果响应
|
* @return 设置结果响应
|
||||||
*/
|
*/
|
||||||
private R setupPortForwardOutParameters(Tunnel tunnel, TunnelDto tunnelDto) {
|
private R setupPortForwardOutParameters(Tunnel tunnel, TunnelDto tunnelDto, String server_ip) {
|
||||||
tunnel.setOutNodeId(tunnelDto.getInNodeId());
|
tunnel.setOutNodeId(tunnelDto.getInNodeId());
|
||||||
tunnel.setOutIp(tunnel.getInIp());
|
tunnel.setOutIp(server_ip);
|
||||||
tunnel.setOutIpSta(tunnelDto.getInPortSta());
|
tunnel.setOutIpSta(tunnelDto.getInPortSta());
|
||||||
tunnel.setOutIpEnd(tunnelDto.getInPortEnd());
|
tunnel.setOutIpEnd(tunnelDto.getInPortEnd());
|
||||||
return R.ok();
|
return R.ok();
|
||||||
|
|||||||
@@ -88,13 +88,17 @@
|
|||||||
size="mini"
|
size="mini"
|
||||||
type="text"
|
type="text"
|
||||||
icon="el-icon-copy-document"
|
icon="el-icon-copy-document"
|
||||||
@click="copyToClipboard(formatInAddress(forward.inIp, forward.inPort), '入口地址')"
|
@click="copyToClipboard(getCopyInAddress(forward.inIp, forward.inPort), '入口地址')"
|
||||||
class="copy-btn"
|
class="copy-btn"
|
||||||
title="复制入口地址"
|
title="复制入口地址"
|
||||||
></el-button>
|
></el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="address-value">
|
<div class="address-value">
|
||||||
<span class="value address-text" :title="formatInAddress(forward.inIp, forward.inPort)">
|
<span
|
||||||
|
class="value address-text"
|
||||||
|
@click="showAddressDialog(forward.inIp, forward.inPort, '入口地址')"
|
||||||
|
:class="{ 'clickable': hasMultipleIps(forward.inIp) }"
|
||||||
|
>
|
||||||
{{ formatInAddress(forward.inIp, forward.inPort) }}
|
{{ formatInAddress(forward.inIp, forward.inPort) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +121,7 @@
|
|||||||
></el-button>
|
></el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="address-value">
|
<div class="address-value">
|
||||||
<span class="value address-text" :title="forward.remoteAddr">
|
<span class="value address-text">
|
||||||
{{ forward.remoteAddr }}
|
{{ forward.remoteAddr }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,6 +247,65 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
</el-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -277,6 +340,11 @@ export default {
|
|||||||
dialogTitle: '',
|
dialogTitle: '',
|
||||||
isEdit: false,
|
isEdit: false,
|
||||||
|
|
||||||
|
// 地址列表弹窗状态
|
||||||
|
addressDialogVisible: false,
|
||||||
|
addressDialogTitle: '',
|
||||||
|
addressList: [],
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
forwardForm: {
|
forwardForm: {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -558,8 +626,10 @@ export default {
|
|||||||
tunnel.id === tunnelId
|
tunnel.id === tunnelId
|
||||||
) || null;
|
) || null;
|
||||||
|
|
||||||
// 清空端口输入,避免与新隧道的端口范围冲突
|
// 只在新增模式下清空端口输入,编辑模式下保留原端口值
|
||||||
this.forwardForm.inPort = null;
|
if (!this.isEdit) {
|
||||||
|
this.forwardForm.inPort = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 触发端口字段重新验证
|
// 触发端口字段重新验证
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -642,12 +712,12 @@ export default {
|
|||||||
if (tunnel.name) {
|
if (tunnel.name) {
|
||||||
// 显示隧道名称和IP信息
|
// 显示隧道名称和IP信息
|
||||||
if (tunnel.ip) {
|
if (tunnel.ip) {
|
||||||
return `${tunnel.name} (${tunnel.ip})`;
|
return `${tunnel.name}`;
|
||||||
}
|
}
|
||||||
return tunnel.name;
|
return tunnel.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `隧道ID: ${tunnel.id || 'unknown'}`;
|
return `隧道ID: ${tunnel.id}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
formatFlow(value) {
|
formatFlow(value) {
|
||||||
@@ -658,18 +728,147 @@ export default {
|
|||||||
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||||
},
|
},
|
||||||
|
|
||||||
// 格式化入口地址,IPv6需要用[]包裹
|
// 格式化入口地址,IPv6需要用[]包裹,支持多IP优化显示
|
||||||
formatInAddress(ip, port) {
|
formatInAddress(ipString, port) {
|
||||||
if (!ip || !port) return '';
|
if (!ipString || !port) return '';
|
||||||
|
|
||||||
// 检查是否为IPv6地址
|
// 分割IP字符串,处理多个IP的情况
|
||||||
if (ip.includes(':')) {
|
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||||
// IPv6地址需要用方括号包裹
|
|
||||||
return `[${ip}]:${port}`;
|
if (ips.length === 0) return '';
|
||||||
} else {
|
|
||||||
// IPv4地址直接拼接
|
// 只有一个IP时,正常格式化
|
||||||
return `${ip}:${port}`;
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 复制单个地址
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 复制到剪贴板
|
// 复制到剪贴板
|
||||||
@@ -1221,6 +1420,18 @@ export default {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%; /* 确保不会超出容器 */
|
max-width: 100%; /* 确保不会超出容器 */
|
||||||
box-sizing: border-box;
|
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 {
|
.copy-btn {
|
||||||
@@ -1355,5 +1566,190 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 地址列表弹窗样式 */
|
||||||
|
.address-list-dialog .el-dialog__body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list-container {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px 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: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
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: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-all-btn {
|
||||||
|
align-self: center;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-content {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-text-full {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-single-btn {
|
||||||
|
align-self: center;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 超小屏幕优化 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.address-list-dialog .el-dialog__body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list-header {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list-item {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-text-full {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-text {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -191,7 +191,13 @@
|
|||||||
<div class="forward-info">
|
<div class="forward-info">
|
||||||
<div class="forward-name">{{ forward.name }}</div>
|
<div class="forward-name">{{ forward.name }}</div>
|
||||||
<div class="forward-address">
|
<div class="forward-address">
|
||||||
<div class="address-source">{{ forward.inIp }}:{{ forward.inPort }}</div>
|
<div
|
||||||
|
class="address-source"
|
||||||
|
@click="showAddressDialog(forward.inIp, forward.inPort, '入口地址')"
|
||||||
|
:class="{ 'clickable': hasMultipleIps(forward.inIp) }"
|
||||||
|
>
|
||||||
|
{{ formatInAddress(forward.inIp, forward.inPort) }}
|
||||||
|
</div>
|
||||||
<div class="address-arrow">↓</div>
|
<div class="address-arrow">↓</div>
|
||||||
<div class="address-target">{{ forward.remoteAddr }}</div>
|
<div class="address-target">{{ forward.remoteAddr }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,6 +221,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 地址列表弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
:title="addressDialogTitle"
|
||||||
|
:visible.sync="addressDialogVisible"
|
||||||
|
width="600px"
|
||||||
|
:before-close="() => { addressDialogVisible = false }"
|
||||||
|
>
|
||||||
|
<div class="address-list">
|
||||||
|
<div class="list-header">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="copyAllAddresses"
|
||||||
|
icon="el-icon-copy-document"
|
||||||
|
>
|
||||||
|
复制全部
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="addresses">
|
||||||
|
<div
|
||||||
|
v-for="item in addressList"
|
||||||
|
:key="item.id"
|
||||||
|
class="address-item"
|
||||||
|
>
|
||||||
|
<div class="address-text">{{ item.address }}</div>
|
||||||
|
<el-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
:loading="item.copying"
|
||||||
|
@click="copyAddress(item)"
|
||||||
|
icon="el-icon-copy-document"
|
||||||
|
class="copy-btn"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -230,6 +277,11 @@ export default {
|
|||||||
userInfo: {},
|
userInfo: {},
|
||||||
userTunnels: [],
|
userTunnels: [],
|
||||||
forwardList: [],
|
forwardList: [],
|
||||||
|
|
||||||
|
// 地址列表弹窗状态
|
||||||
|
addressDialogVisible: false,
|
||||||
|
addressDialogTitle: '',
|
||||||
|
addressList: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -487,7 +539,123 @@ export default {
|
|||||||
// 双向计算:计算上传和下载的总流量
|
// 双向计算:计算上传和下载的总流量
|
||||||
return inFlow + outFlow;
|
return inFlow + outFlow;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 格式化入口地址,支持多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}个入口`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查是否有多个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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 复制单个地址
|
||||||
|
async copyAddress(addressItem) {
|
||||||
|
try {
|
||||||
|
this.$set(addressItem, 'copying', true);
|
||||||
|
await this.copyToClipboard(addressItem.address, '地址');
|
||||||
|
} 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 this.copyToClipboard(allAddresses, '所有地址');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 复制到剪贴板
|
||||||
|
async copyToClipboard(text, label = '内容') {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
this.$message.success(`${label}已复制到剪贴板`);
|
||||||
|
} catch (error) {
|
||||||
|
// 降级方案:使用传统方法
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
this.$message.success(`${label}已复制到剪贴板`);
|
||||||
|
} catch (err) {
|
||||||
|
this.$message.error('复制失败,请手动复制');
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
filters: {
|
filters: {
|
||||||
@@ -1177,4 +1345,93 @@ export default {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 可点击地址样式 */
|
||||||
|
.address-source.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-source.clickable:hover {
|
||||||
|
background-color: #d9ecff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 地址列表弹窗样式 */
|
||||||
|
.address-list {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addresses {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-item:hover {
|
||||||
|
background: #e8f4fd;
|
||||||
|
border-color: #409EFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-text {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
margin-left: 12px;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #409EFF !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端弹窗优化 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.el-dialog {
|
||||||
|
width: 90% !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-text {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<div class="flow-detail">
|
<div class="flow-detail">
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="label">IP:</span>
|
<span class="label">IP:</span>
|
||||||
<span class="value" :title="tunnel.inIp">{{ tunnel.inIp }}</span>
|
<span class="value" :title="getFullIpList(tunnel.inIp)">{{ getDisplayIp(tunnel.inIp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="label">端口:</span>
|
<span class="label">端口:</span>
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
|
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="label">IP:</span>
|
<span class="label">IP:</span>
|
||||||
<span class="value" :title="tunnel.outIp">{{ tunnel.outIp }}</span>
|
<span class="value" :title="getFullIpList(tunnel.outIp)">{{ getDisplayIp(tunnel.outIp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
@@ -717,6 +717,32 @@ export default {
|
|||||||
if (this.$refs.tunnelForm) {
|
if (this.$refs.tunnelForm) {
|
||||||
this.$refs.tunnelForm.clearValidate();
|
this.$refs.tunnelForm.clearValidate();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取显示的IP(优化多IP显示)
|
||||||
|
getDisplayIp(ipString) {
|
||||||
|
if (!ipString) return '-';
|
||||||
|
|
||||||
|
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||||
|
|
||||||
|
if (ips.length === 0) return '-';
|
||||||
|
if (ips.length === 1) return ips[0];
|
||||||
|
|
||||||
|
// 多个IP时显示第一个 + 数量提示
|
||||||
|
return `${ips[0]} 等${ips.length}个入口IP`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取完整的IP列表(用于title显示)
|
||||||
|
getFullIpList(ipString) {
|
||||||
|
if (!ipString) return '无IP配置';
|
||||||
|
|
||||||
|
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||||
|
|
||||||
|
if (ips.length === 0) return '无IP配置';
|
||||||
|
if (ips.length === 1) return ips[0];
|
||||||
|
|
||||||
|
// 多个IP时在title中显示所有IP
|
||||||
|
return `入口IP列表 (${ips.length}个):\n${ips.join('\n')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -913,15 +939,32 @@ export default {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item .label {
|
.detail-item .label {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item .value {
|
.detail-item .value {
|
||||||
color: #303133;
|
color: #303133;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
cursor: help;
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px dotted transparent;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value:hover {
|
||||||
|
border-bottom-color: #409eff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-arrow {
|
.flow-arrow {
|
||||||
@@ -1184,16 +1227,26 @@ export default {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item .value {
|
.detail-item .value {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
display: inline-block;
|
max-width: 120px;
|
||||||
max-width: 140px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
vertical-align: top;
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted transparent;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value:hover {
|
||||||
|
border-bottom-color: #409eff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-arrow {
|
.flow-arrow {
|
||||||
@@ -1379,15 +1432,15 @@ export default {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #409eff;
|
color: #409eff;
|
||||||
display: inline-block;
|
max-width: 100px;
|
||||||
max-width: 120px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
vertical-align: top;
|
|
||||||
cursor: help;
|
cursor: help;
|
||||||
border-bottom: 1px dotted transparent;
|
border-bottom: 1px dotted transparent;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item .value:hover {
|
.detail-item .value:hover {
|
||||||
@@ -1500,16 +1553,26 @@ export default {
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item .value {
|
.detail-item .value {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
display: inline-block;
|
max-width: 80px;
|
||||||
max-width: 100px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
vertical-align: top;
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted transparent;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value:hover {
|
||||||
|
border-bottom-color: #409eff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+363
-43
@@ -198,38 +198,91 @@
|
|||||||
|
|
||||||
<el-form-item label="入口IP" prop="ipList">
|
<el-form-item label="入口IP" prop="ipList">
|
||||||
<div class="ip-input-container">
|
<div class="ip-input-container">
|
||||||
<!-- 已添加的IP标签 -->
|
<!-- IP输入区域 -->
|
||||||
<div class="ip-tags" v-if="nodeForm.ipList.length > 0">
|
<div class="ip-input-section">
|
||||||
<el-tag
|
|
||||||
v-for="(ip, index) in nodeForm.ipList"
|
|
||||||
:key="index"
|
|
||||||
:type="getIpTagType(ip)"
|
|
||||||
size="medium"
|
|
||||||
closable
|
|
||||||
@close="removeIp(index)"
|
|
||||||
class="ip-tag"
|
|
||||||
>
|
|
||||||
{{ ip }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 输入新IP -->
|
|
||||||
<div class="ip-input-wrapper">
|
|
||||||
<el-input
|
<el-input
|
||||||
v-model="newIpInput"
|
v-model="newIpInput"
|
||||||
placeholder="请输入IP地址或域名,如: 192.168.1.1、2001:db8::1 或 example.com"
|
placeholder="输入IP地址或域名,按Enter添加"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter.native="addIp"
|
@keyup.enter.native="addIp"
|
||||||
@blur="addIp"
|
|
||||||
size="medium"
|
size="medium"
|
||||||
class="ip-input"
|
class="ip-input"
|
||||||
|
:disabled="nodeForm.ipList.length >= 10"
|
||||||
>
|
>
|
||||||
<template slot="append">
|
<template slot="append">
|
||||||
<el-button @click="addIp" icon="el-icon-plus" size="medium">添加</el-button>
|
<el-button
|
||||||
|
@click="addIp"
|
||||||
|
icon="el-icon-plus"
|
||||||
|
size="medium"
|
||||||
|
:disabled="nodeForm.ipList.length >= 10 || !newIpInput.trim()"
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
|
<!-- 添加提示 -->
|
||||||
|
<div class="ip-input-tips">
|
||||||
|
<div class="tip-item">
|
||||||
|
<i class="el-icon-info"></i>
|
||||||
|
<span>支持IPv4、IPv6地址和域名格式</span>
|
||||||
|
</div>
|
||||||
|
<div class="tip-item count-tip">
|
||||||
|
<i class="el-icon-document"></i>
|
||||||
|
<span>已添加 {{ nodeForm.ipList.length }} / 10 个IP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 已添加的IP列表 -->
|
||||||
|
<div class="ip-list-section" v-if="nodeForm.ipList.length > 0">
|
||||||
|
<div class="ip-list-header">
|
||||||
|
<span class="list-title">
|
||||||
|
<i class="el-icon-collection-tag"></i>
|
||||||
|
已配置的入口IP
|
||||||
|
</span>
|
||||||
|
<el-button
|
||||||
|
size="mini"
|
||||||
|
type="text"
|
||||||
|
@click="clearAllIps"
|
||||||
|
class="clear-all-btn"
|
||||||
|
>
|
||||||
|
<i class="el-icon-delete"></i>
|
||||||
|
清空全部
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ip-tags-grid">
|
||||||
|
<div
|
||||||
|
v-for="(ip, index) in nodeForm.ipList"
|
||||||
|
:key="index"
|
||||||
|
class="ip-tag-item"
|
||||||
|
>
|
||||||
|
<el-tag
|
||||||
|
:type="getIpTagType(ip)"
|
||||||
|
size="medium"
|
||||||
|
closable
|
||||||
|
@close="removeIp(index)"
|
||||||
|
class="ip-tag"
|
||||||
|
>
|
||||||
|
<i :class="getIpIcon(ip)" class="ip-icon"></i>
|
||||||
|
{{ ip }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态提示 -->
|
||||||
|
<div class="ip-empty-state" v-else>
|
||||||
|
<i class="el-icon-plus"></i>
|
||||||
|
<p>请添加至少一个入口IP地址</p>
|
||||||
|
<div class="example-ips">
|
||||||
|
<span class="example-label">示例:</span>
|
||||||
|
<el-tag size="mini" @click="fillExample('192.168.1.100')" class="example-tag">192.168.1.100</el-tag>
|
||||||
|
<el-tag size="mini" @click="fillExample('example.com')" class="example-tag">example.com</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
@@ -970,6 +1023,17 @@ export default {
|
|||||||
|
|
||||||
const ip = this.newIpInput.trim();
|
const ip = this.newIpInput.trim();
|
||||||
|
|
||||||
|
// 检查数量限制
|
||||||
|
if (this.nodeForm.ipList.length >= 10) {
|
||||||
|
this.$message({
|
||||||
|
message: '最多只能添加10个IP地址',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 3000,
|
||||||
|
showClose: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 验证IP格式
|
// 验证IP格式
|
||||||
if (!this.validateIp(ip)) {
|
if (!this.validateIp(ip)) {
|
||||||
this.$message({
|
this.$message({
|
||||||
@@ -1003,15 +1067,11 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.$message({
|
|
||||||
message: `已添加IP地址: ${ip}`,
|
|
||||||
type: 'success',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 移除IP地址
|
// 移除IP地址
|
||||||
removeIp(index) {
|
removeIp(index) {
|
||||||
|
const removedIp = this.nodeForm.ipList[index];
|
||||||
this.nodeForm.ipList.splice(index, 1);
|
this.nodeForm.ipList.splice(index, 1);
|
||||||
|
|
||||||
// 触发表单验证
|
// 触发表单验证
|
||||||
@@ -1020,6 +1080,82 @@ export default {
|
|||||||
this.$refs.nodeForm.validateField('ipList');
|
this.$refs.nodeForm.validateField('ipList');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空所有IP地址
|
||||||
|
clearAllIps() {
|
||||||
|
this.$confirm('确定要清空所有IP地址吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
const count = this.nodeForm.ipList.length;
|
||||||
|
this.nodeForm.ipList = [];
|
||||||
|
|
||||||
|
// 触发表单验证
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.nodeForm) {
|
||||||
|
this.$refs.nodeForm.validateField('ipList');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$message({
|
||||||
|
message: `已清空 ${count} 个IP地址`,
|
||||||
|
type: 'success',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// 用户取消
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 填充示例IP
|
||||||
|
fillExample(exampleIp) {
|
||||||
|
if (this.nodeForm.ipList.includes(exampleIp)) {
|
||||||
|
this.$message({
|
||||||
|
message: '该示例IP已存在',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.nodeForm.ipList.length >= 10) {
|
||||||
|
this.$message({
|
||||||
|
message: '最多只能添加10个IP地址',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.newIpInput = exampleIp;
|
||||||
|
// 自动聚焦到输入框
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const input = this.$el.querySelector('.ip-input input');
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取IP类型图标
|
||||||
|
getIpIcon(ip) {
|
||||||
|
// 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格式验证
|
||||||
|
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(ip)) {
|
||||||
|
return 'el-icon-monitor'; // IPv4
|
||||||
|
} else if (ipv6Regex.test(ip)) {
|
||||||
|
return 'el-icon-cpu'; // IPv6
|
||||||
|
} else {
|
||||||
|
return 'el-icon-link'; // 域名
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 验证单个IP地址(用于服务器IP验证)
|
// 验证单个IP地址(用于服务器IP验证)
|
||||||
@@ -1635,49 +1771,233 @@ export default {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-tags {
|
/* IP输入区域 */
|
||||||
margin-bottom: 12px;
|
.ip-input-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-input-tips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-item i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-tip {
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IP列表区域 */
|
||||||
|
.ip-list-section {
|
||||||
|
background: #fafbfc;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title i {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-all-btn {
|
||||||
|
color: #f56c6c;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-all-btn:hover {
|
||||||
|
color: #f02d2d;
|
||||||
|
background: rgba(245, 108, 108, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-tags-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ip-tag-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.ip-tag {
|
.ip-tag {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.ip-input-wrapper {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ip-input {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-help-text {
|
.ip-tag:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态样式 */
|
||||||
|
.ip-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
background: #fafbfc;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-empty-state i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-ips {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
line-height: 1.4;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-help-text span {
|
.example-tag {
|
||||||
display: block;
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-tag:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式样式 */
|
/* 响应式样式 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.ip-tags {
|
.ip-input-tips {
|
||||||
margin-bottom: 8px;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-list-section {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-list-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-all-btn {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-tags-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-tag {
|
.ip-tag {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
padding: 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-help-text {
|
.ip-empty-state {
|
||||||
font-size: 11px;
|
padding: 30px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-empty-state i {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-ips {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-label {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.ip-input-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-list-section {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-tags-grid {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-empty-state {
|
||||||
|
padding: 20px 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user