From bb3bc4d257c9dd6efc9fa245e56d982fa728f57d Mon Sep 17 00:00:00 2001 From: qaq <1937228092@qq.com> Date: Wed, 25 Jun 2025 13:17:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=98=BE=E7=A4=BA=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/ForwardServiceImpl.java | 3 +- .../admin/service/impl/TunnelServiceImpl.java | 12 +- vue-frontend/src/views/Forward.vue | 430 +++++++++++++++++- vue-frontend/src/views/Index.vue | 259 ++++++++++- vue-frontend/src/views/Tunnel.vue | 85 +++- vue-frontend/src/views/node.vue | 408 +++++++++++++++-- 6 files changed, 1117 insertions(+), 80 deletions(-) diff --git a/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java index 1fa5cf2..34ee5a9 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java @@ -565,6 +565,8 @@ public class ForwardServiceImpl extends ServiceImpl impl */ private Forward createForwardEntity(ForwardDto forwardDto, UserInfo currentUser, PortAllocation portAllocation) { Forward forward = new Forward(); + // 先复制DTO的属性,再设置其他属性,避免被覆盖 + BeanUtils.copyProperties(forwardDto, forward); forward.setStatus(FORWARD_STATUS_ACTIVE); forward.setInPort(portAllocation.getInPort()); forward.setOutPort(portAllocation.getOutPort()); @@ -572,7 +574,6 @@ public class ForwardServiceImpl extends ServiceImpl impl forward.setUserName(currentUser.getUserName()); forward.setCreatedTime(System.currentTimeMillis()); forward.setUpdatedTime(System.currentTimeMillis()); - BeanUtils.copyProperties(forwardDto, forward); return forward; } diff --git a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java index 5572574..d5b7980 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java @@ -129,7 +129,7 @@ public class TunnelServiceImpl extends ServiceImpl impleme Tunnel tunnel = buildTunnelEntity(tunnelDto, inNodeValidation.getNode()); // 5. 根据隧道类型设置出口参数 - R outNodeSetupResult = setupOutNodeParameters(tunnel, tunnelDto); + R outNodeSetupResult = setupOutNodeParameters(tunnel, tunnelDto, inNodeValidation.getNode().getServerIp()); if (outNodeSetupResult.getCode() != 0) { return outNodeSetupResult; } @@ -408,7 +408,7 @@ public class TunnelServiceImpl extends ServiceImpl impleme // 设置入口节点信息 tunnel.setInNodeId(tunnelDto.getInNodeId()); - tunnel.setInIp(inNode.getServerIp()); + tunnel.setInIp(inNode.getIp()); // 设置流量计算类型 tunnel.setFlow(tunnelDto.getFlow()); @@ -439,10 +439,10 @@ public class TunnelServiceImpl extends ServiceImpl impleme * @param tunnelDto 隧道创建DTO * @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) { // 端口转发:出口参数使用入口参数 - return setupPortForwardOutParameters(tunnel, tunnelDto); + return setupPortForwardOutParameters(tunnel, tunnelDto, server_ip); } else { // 隧道转发:需要验证出口参数 return setupTunnelForwardOutParameters(tunnel, tunnelDto); @@ -456,9 +456,9 @@ public class TunnelServiceImpl extends ServiceImpl impleme * @param tunnelDto 隧道创建DTO * @return 设置结果响应 */ - private R setupPortForwardOutParameters(Tunnel tunnel, TunnelDto tunnelDto) { + private R setupPortForwardOutParameters(Tunnel tunnel, TunnelDto tunnelDto, String server_ip) { tunnel.setOutNodeId(tunnelDto.getInNodeId()); - tunnel.setOutIp(tunnel.getInIp()); + tunnel.setOutIp(server_ip); tunnel.setOutIpSta(tunnelDto.getInPortSta()); tunnel.setOutIpEnd(tunnelDto.getInPortEnd()); return R.ok(); diff --git a/vue-frontend/src/views/Forward.vue b/vue-frontend/src/views/Forward.vue index cbcbd8b..9483e8b 100644 --- a/vue-frontend/src/views/Forward.vue +++ b/vue-frontend/src/views/Forward.vue @@ -88,13 +88,17 @@ size="mini" type="text" icon="el-icon-copy-document" - @click="copyToClipboard(formatInAddress(forward.inIp, forward.inPort), '入口地址')" + @click="copyToClipboard(getCopyInAddress(forward.inIp, forward.inPort), '入口地址')" class="copy-btn" title="复制入口地址" >
- + {{ formatInAddress(forward.inIp, forward.inPort) }}
@@ -117,7 +121,7 @@ >
- + {{ forward.remoteAddr }}
@@ -243,6 +247,65 @@ + + + +
+ +
+
+ + 点击地址右侧按钮可单独复制 +
+ + 复制全部 + +
+ + +
+
+
+
+ + {{ addressItem.ip }} +
+
{{ addressItem.address }}
+
+ + 复制 + +
+
+
+ + + 关 闭 + +
@@ -277,6 +340,11 @@ export default { dialogTitle: '', isEdit: false, + // 地址列表弹窗状态 + addressDialogVisible: false, + addressDialogTitle: '', + addressList: [], + // 表单数据 forwardForm: { id: null, @@ -558,8 +626,10 @@ export default { tunnel.id === tunnelId ) || null; - // 清空端口输入,避免与新隧道的端口范围冲突 - this.forwardForm.inPort = null; + // 只在新增模式下清空端口输入,编辑模式下保留原端口值 + if (!this.isEdit) { + this.forwardForm.inPort = null; + } // 触发端口字段重新验证 this.$nextTick(() => { @@ -642,12 +712,12 @@ export default { if (tunnel.name) { // 显示隧道名称和IP信息 if (tunnel.ip) { - return `${tunnel.name} (${tunnel.ip})`; + return `${tunnel.name}`; } return tunnel.name; } - return `隧道ID: ${tunnel.id || 'unknown'}`; + return `隧道ID: ${tunnel.id}`; }, formatFlow(value) { @@ -658,20 +728,149 @@ export default { return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }, - // 格式化入口地址,IPv6需要用[]包裹 - formatInAddress(ip, port) { - if (!ip || !port) return ''; + // 格式化入口地址,IPv6需要用[]包裹,支持多IP优化显示 + formatInAddress(ipString, port) { + if (!ipString || !port) return ''; - // 检查是否为IPv6地址 - if (ip.includes(':')) { - // IPv6地址需要用方括号包裹 - return `[${ip}]:${port}`; - } else { - // IPv4地址直接拼接 - return `${ip}:${port}`; + // 分割IP字符串,处理多个IP的情况 + const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip); + + if (ips.length === 0) return ''; + + // 只有一个IP时,正常格式化 + if (ips.length === 1) { + const ip = ips[0]; + // 检查是否为IPv6地址 + if (ip.includes(':') && !ip.startsWith('[')) { + // IPv6地址需要用方括号包裹 + return `[${ip}]:${port}`; + } else { + // IPv4地址或已包含方括号的IPv6地址直接拼接 + return `${ip}:${port}`; + } } + + // 多个IP时,显示第一个IP + 数量提示 + const firstIp = ips[0]; + let formattedFirstIp; + + // 格式化第一个IP + if (firstIp.includes(':') && !firstIp.startsWith('[')) { + formattedFirstIp = `[${firstIp}]`; + } else { + formattedFirstIp = firstIp; + } + + return `${formattedFirstIp}:${port} 等${ips.length}个入口`; }, + // 获取完整的入口地址列表(用于title显示) + getFullInAddressList(ipString, port) { + if (!ipString || !port) return '无入口地址配置'; + + const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip); + + if (ips.length === 0) return '无入口地址配置'; + if (ips.length === 1) { + // 单个IP时,直接返回格式化后的地址 + return this.formatInAddress(ipString, port); + } + + // 多个IP时在title中显示所有地址 + const formattedAddresses = ips.map(ip => { + if (ip.includes(':') && !ip.startsWith('[')) { + return `[${ip}]:${port}`; + } else { + return `${ip}:${port}`; + } + }); + + return `入口地址列表 (${ips.length}个):\n${formattedAddresses.join('\n')}`; + }, + + // 获取复制用的入口地址(支持多IP) + getCopyInAddress(ipString, port) { + if (!ipString || !port) return ''; + + const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip); + + if (ips.length === 0) return ''; + if (ips.length === 1) { + // 单个IP时,返回格式化后的地址 + return this.formatInAddress(ipString, port); + } + + // 多个IP时返回所有地址,用换行符分隔 + const formattedAddresses = ips.map(ip => { + if (ip.includes(':') && !ip.startsWith('[')) { + return `[${ip}]:${port}`; + } else { + return `${ip}:${port}`; + } + }); + + return formattedAddresses.join('\n'); + }, + + // 检查是否有多个IP + hasMultipleIps(ipString) { + if (!ipString) return false; + const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip); + return ips.length > 1; + }, + + // 显示地址列表弹窗 + showAddressDialog(ipString, port, title) { + if (!ipString || !port) return; + + const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip); + + // 如果只有一个IP,直接复制,不显示弹窗 + if (ips.length <= 1) { + this.copyToClipboard(this.formatInAddress(ipString, port), title); + return; + } + + // 格式化地址列表 + this.addressList = ips.map((ip, index) => { + let formattedAddress; + if (ip.includes(':') && !ip.startsWith('[')) { + formattedAddress = `[${ip}]:${port}`; + } else { + formattedAddress = `${ip}:${port}`; + } + return { + id: index, + ip: ip, + address: formattedAddress, + copying: false // 复制状态 + }; + }); + + this.addressDialogTitle = `${title}列表 (共${ips.length}个)`; + this.addressDialogVisible = true; + }, + + // 复制单个地址 + async copyAddress(addressItem) { + try { + this.$set(addressItem, 'copying', true); + await copyWithMessage(addressItem.address, '地址', this); + } catch (error) { + this.$message.error('复制失败'); + } finally { + this.$set(addressItem, 'copying', false); + } + }, + + // 复制所有地址 + async copyAllAddresses() { + if (this.addressList.length === 0) return; + + const allAddresses = this.addressList.map(item => item.address).join('\n'); + await copyWithMessage(allAddresses, '所有地址', this); + }, + // 复制到剪贴板 async copyToClipboard(text, label = '内容') { await copyWithMessage(text, label, this); @@ -1221,6 +1420,18 @@ export default { width: 100%; max-width: 100%; /* 确保不会超出容器 */ box-sizing: border-box; + font-family: monospace; + color: #409EFF; + font-weight: 500; +} + +.address-text.clickable { + cursor: pointer; + transition: background-color 0.2s; +} + +.address-text.clickable:hover { + background-color: rgba(64, 158, 255, 0.1); } .copy-btn { @@ -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; + } +} \ No newline at end of file diff --git a/vue-frontend/src/views/Index.vue b/vue-frontend/src/views/Index.vue index 2256872..22c671a 100644 --- a/vue-frontend/src/views/Index.vue +++ b/vue-frontend/src/views/Index.vue @@ -191,7 +191,13 @@
{{ forward.name }}
-
{{ forward.inIp }}:{{ forward.inPort }}
+
+ {{ formatInAddress(forward.inIp, forward.inPort) }} +
↓
{{ forward.remoteAddr }}
@@ -215,6 +221,47 @@
+ + + +
+
+ + 复制全部 + +
+ +
+
+
{{ item.address }}
+ + 复制 + +
+
+
+
@@ -230,6 +277,11 @@ export default { userInfo: {}, userTunnels: [], forwardList: [], + + // 地址列表弹窗状态 + addressDialogVisible: false, + addressDialogTitle: '', + addressList: [], }; }, computed: { @@ -487,7 +539,123 @@ export default { // 双向计算:计算上传和下载的总流量 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: { @@ -1177,4 +1345,93 @@ export default { 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; + } +} \ No newline at end of file diff --git a/vue-frontend/src/views/Tunnel.vue b/vue-frontend/src/views/Tunnel.vue index 01513d4..f196159 100644 --- a/vue-frontend/src/views/Tunnel.vue +++ b/vue-frontend/src/views/Tunnel.vue @@ -87,7 +87,7 @@
IP: - {{ tunnel.inIp }} + {{ getDisplayIp(tunnel.inIp) }}
端口: @@ -113,7 +113,7 @@
IP: - {{ tunnel.outIp }} + {{ getDisplayIp(tunnel.outIp) }}
@@ -717,6 +717,32 @@ export default { if (this.$refs.tunnelForm) { 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; color: #909399; margin-bottom: 2px; + display: flex; + align-items: baseline; + gap: 4px; } .detail-item .label { font-weight: 500; + flex-shrink: 0; } .detail-item .value { color: #303133; 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 { @@ -1184,16 +1227,26 @@ export default { font-size: 11px; margin-bottom: 2px; line-height: 1.3; + display: flex; + align-items: baseline; + gap: 4px; } .detail-item .value { font-size: 12px; - display: inline-block; - max-width: 140px; + max-width: 120px; overflow: hidden; text-overflow: ellipsis; 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 { @@ -1379,15 +1432,15 @@ export default { font-size: 11px; font-weight: 500; color: #409eff; - display: inline-block; - max-width: 120px; + max-width: 100px; overflow: hidden; text-overflow: ellipsis; 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 { @@ -1500,16 +1553,26 @@ export default { font-size: 9px; margin-bottom: 2px; line-height: 1.3; + display: flex; + align-items: baseline; + gap: 4px; } .detail-item .value { font-size: 10px; - display: inline-block; - max-width: 100px; + max-width: 80px; overflow: hidden; text-overflow: ellipsis; 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; } } diff --git a/vue-frontend/src/views/node.vue b/vue-frontend/src/views/node.vue index 4ff84f5..e9c4e6b 100644 --- a/vue-frontend/src/views/node.vue +++ b/vue-frontend/src/views/node.vue @@ -198,38 +198,91 @@
- -
- - {{ ip }} - -
- - -
+ +
+ + +
+
+ + 支持IPv4、IPv6地址和域名格式 +
+
+ + 已添加 {{ nodeForm.ipList.length }} / 10 个IP +
+
+
+ + +
+
+ + + 已配置的入口IP + + + + 清空全部 + +
+ +
+
+ + + {{ ip }} + +
+
+
+ + +
+ +

请添加至少一个入口IP地址

+
+ 示例: + 192.168.1.100 + example.com +
-
@@ -970,6 +1023,17 @@ export default { const ip = this.newIpInput.trim(); + // 检查数量限制 + if (this.nodeForm.ipList.length >= 10) { + this.$message({ + message: '最多只能添加10个IP地址', + type: 'warning', + duration: 3000, + showClose: true + }); + return; + } + // 验证IP格式 if (!this.validateIp(ip)) { this.$message({ @@ -1003,15 +1067,11 @@ export default { } }); - this.$message({ - message: `已添加IP地址: ${ip}`, - type: 'success', - duration: 2000 - }); }, // 移除IP地址 removeIp(index) { + const removedIp = this.nodeForm.ipList[index]; this.nodeForm.ipList.splice(index, 1); // 触发表单验证 @@ -1020,6 +1080,82 @@ export default { 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验证) @@ -1635,49 +1771,233 @@ export default { width: 100%; } -.ip-tags { - margin-bottom: 12px; +/* IP输入区域 */ +.ip-input-section { + margin-bottom: 16px; +} + +.ip-input { + width: 100%; + margin-bottom: 8px; +} + +.ip-input-tips { 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; } +.ip-tag-item { + display: flex; + align-items: center; +} + .ip-tag { font-family: monospace; font-size: 12px; -} - -.ip-input-wrapper { - margin-bottom: 8px; -} - -.ip-input { 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; color: #909399; - line-height: 1.4; + margin-right: 4px; } -.ip-help-text span { - display: block; +.example-tag { + 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) { - .ip-tags { - margin-bottom: 8px; + .ip-input-tips { + 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; } .ip-tag { font-size: 11px; + padding: 5px 10px; } - .ip-help-text { - font-size: 11px; + .ip-empty-state { + 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; } }