mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 07:56: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) {
|
||||
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<ForwardMapper, Forward> impl
|
||||
forward.setUserName(currentUser.getUserName());
|
||||
forward.setCreatedTime(System.currentTimeMillis());
|
||||
forward.setUpdatedTime(System.currentTimeMillis());
|
||||
BeanUtils.copyProperties(forwardDto, forward);
|
||||
return forward;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> 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<TunnelMapper, Tunnel> 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<TunnelMapper, Tunnel> 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<TunnelMapper, Tunnel> 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();
|
||||
|
||||
@@ -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="复制入口地址"
|
||||
></el-button>
|
||||
</div>
|
||||
<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) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -117,7 +121,7 @@
|
||||
></el-button>
|
||||
</div>
|
||||
<div class="address-value">
|
||||
<span class="value address-text" :title="forward.remoteAddr">
|
||||
<span class="value address-text">
|
||||
{{ forward.remoteAddr }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -243,6 +247,65 @@
|
||||
</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 地址列表弹窗 -->
|
||||
<el-dialog
|
||||
:title="addressDialogTitle"
|
||||
:visible.sync="addressDialogVisible"
|
||||
:width="isMobile ? '90%' : '500px'"
|
||||
class="address-list-dialog"
|
||||
>
|
||||
<div class="address-list-container">
|
||||
<!-- 头部操作 -->
|
||||
<div class="address-list-header">
|
||||
<div class="header-info">
|
||||
<i class="el-icon-connection"></i>
|
||||
<span>点击地址右侧按钮可单独复制</span>
|
||||
</div>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="primary"
|
||||
icon="el-icon-copy-document"
|
||||
@click="copyAllAddresses"
|
||||
class="copy-all-btn"
|
||||
>
|
||||
复制全部
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 地址列表 -->
|
||||
<div class="address-list">
|
||||
<div
|
||||
v-for="addressItem in addressList"
|
||||
:key="addressItem.id"
|
||||
class="address-list-item"
|
||||
>
|
||||
<div class="address-content">
|
||||
<div class="ip-info">
|
||||
<i class="el-icon-position"></i>
|
||||
<span class="ip-text">{{ addressItem.ip }}</span>
|
||||
</div>
|
||||
<div class="address-text-full">{{ addressItem.address }}</div>
|
||||
</div>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-copy-document"
|
||||
:loading="addressItem.copying"
|
||||
@click="copyAddress(addressItem)"
|
||||
class="copy-single-btn"
|
||||
title="复制此地址"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="addressDialogVisible = false">关 闭</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -191,7 +191,13 @@
|
||||
<div class="forward-info">
|
||||
<div class="forward-name">{{ forward.name }}</div>
|
||||
<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-target">{{ forward.remoteAddr }}</div>
|
||||
</div>
|
||||
@@ -215,6 +221,47 @@
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -87,7 +87,7 @@
|
||||
<div class="flow-detail">
|
||||
<div class="detail-item">
|
||||
<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 class="detail-item">
|
||||
<span class="label">端口:</span>
|
||||
@@ -113,7 +113,7 @@
|
||||
|
||||
<div class="detail-item">
|
||||
<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 class="detail-item">
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+364
-44
@@ -198,38 +198,91 @@
|
||||
|
||||
<el-form-item label="入口IP" prop="ipList">
|
||||
<div class="ip-input-container">
|
||||
<!-- 已添加的IP标签 -->
|
||||
<div class="ip-tags" v-if="nodeForm.ipList.length > 0">
|
||||
<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">
|
||||
<!-- IP输入区域 -->
|
||||
<div class="ip-input-section">
|
||||
<el-input
|
||||
v-model="newIpInput"
|
||||
placeholder="请输入IP地址或域名,如: 192.168.1.1、2001:db8::1 或 example.com"
|
||||
placeholder="输入IP地址或域名,按Enter添加"
|
||||
clearable
|
||||
@keyup.enter.native="addIp"
|
||||
@blur="addIp"
|
||||
size="medium"
|
||||
class="ip-input"
|
||||
:disabled="nodeForm.ipList.length >= 10"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 已添加的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>
|
||||
</el-form-item>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user