优化流程

This commit is contained in:
qaq
2025-07-02 15:40:14 +08:00
parent d7339af37b
commit 94e01ae63b
66 changed files with 5973 additions and 1988 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -119,7 +119,7 @@ func main() {
log := xlogger.NewLogger()
logger.SetDefault(log)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, "1.0.2")
defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret)
+2 -2
View File
@@ -781,10 +781,10 @@ func getMemoryInfo() MemoryInfo {
}
// StartWebSocketReporterWithConfig 使用配置启动WebSocket报告器
func StartWebSocketReporterWithConfig(Addr string, Secret string) *WebSocketReporter {
func StartWebSocketReporterWithConfig(Addr string, Secret string, Version string) *WebSocketReporter {
// 构建包含本机IP的WebSocket URL
var fullURL = "ws://" + Addr + "/system-info?type=1&secret=" + Secret
var fullURL = "ws://" + Addr + "/system-info?type=1&secret=" + Secret + "&version=" + Version
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
+14 -15
View File
@@ -3,7 +3,7 @@
-- https://www.phpmyadmin.net/
--
-- 主机: localhost
-- 生成日期: 2025-06-26 14:11:52
-- 生成日期: 2025-07-02 11:33:42
-- 服务器版本: 5.7.40-log
-- PHP 版本: 7.4.33
@@ -35,8 +35,8 @@ CREATE TABLE `forward` (
`tunnel_id` int(10) NOT NULL,
`in_port` int(10) NOT NULL,
`out_port` int(10) DEFAULT NULL,
`remote_addr` longtext NOT NULL,
`strategy` varchar(100) NOT NULL DEFAULT 'fifo' COMMENT '负载均衡策略',
`remote_addr` varchar(100) NOT NULL,
`strategy` varchar(100) NOT NULL DEFAULT 'fifo',
`in_flow` bigint(20) NOT NULL DEFAULT '0',
`out_flow` bigint(20) NOT NULL DEFAULT '0',
`created_time` bigint(20) NOT NULL,
@@ -54,8 +54,11 @@ CREATE TABLE `node` (
`id` int(10) NOT NULL,
`name` varchar(100) NOT NULL,
`secret` varchar(100) NOT NULL,
`ip` longtext DEFAULT NULL,
`ip` longtext,
`server_ip` varchar(100) NOT NULL,
`port_sta` int(10) NOT NULL,
`port_end` int(10) NOT NULL,
`version` varchar(100) DEFAULT NULL,
`created_time` bigint(20) NOT NULL,
`updated_time` bigint(20) DEFAULT NULL,
`status` int(10) NOT NULL
@@ -90,12 +93,8 @@ CREATE TABLE `tunnel` (
`traffic_ratio` decimal(10,1) NOT NULL DEFAULT '1.0',
`in_node_id` int(10) NOT NULL,
`in_ip` varchar(100) NOT NULL,
`in_port_sta` int(10) NOT NULL,
`in_port_end` int(10) NOT NULL,
`out_node_id` int(10) NOT NULL,
`out_ip` varchar(100) NOT NULL,
`out_ip_sta` int(10) NOT NULL,
`out_ip_end` int(10) NOT NULL,
`type` int(10) NOT NULL,
`protocol` varchar(10) NOT NULL DEFAULT 'tls',
`flow` int(10) NOT NULL,
@@ -133,7 +132,7 @@ CREATE TABLE `user` (
--
INSERT INTO `user` (`id`, `user`, `pwd`, `role_id`, `exp_time`, `flow`, `in_flow`, `out_flow`, `flow_reset_time`, `num`, `created_time`, `updated_time`, `status`) VALUES
(1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 1780480500000, 99999, 0, 0, 1, 99999, 1748914865000, 1750910756282, 1);
(1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 1780480500000, 99999, 0, 0, 1, 99999, 1748914865000, 1751113171541, 1);
-- --------------------------------------------------------
@@ -203,37 +202,37 @@ ALTER TABLE `user_tunnel`
-- 使用表AUTO_INCREMENT `forward`
--
ALTER TABLE `forward`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=128;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=164;
--
-- 使用表AUTO_INCREMENT `node`
--
ALTER TABLE `node`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=22;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=27;
--
-- 使用表AUTO_INCREMENT `speed_limit`
--
ALTER TABLE `speed_limit`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=67;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=70;
--
-- 使用表AUTO_INCREMENT `tunnel`
--
ALTER TABLE `tunnel`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=45;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=59;
--
-- 使用表AUTO_INCREMENT `user`
--
ALTER TABLE `user`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=31;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=38;
--
-- 使用表AUTO_INCREMENT `user_tunnel`
--
ALTER TABLE `user_tunnel`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=48;
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=56;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
+128
View File
@@ -458,6 +458,134 @@ PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- node 表:添加 version 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'version'
),
'ALTER TABLE \`node\` ADD COLUMN \`version\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL;',
'SELECT "Column \`version\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- node 表:添加 port_sta 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'port_sta'
),
'ALTER TABLE \`node\` ADD COLUMN \`port_sta\` INT(10) DEFAULT 1000 COMMENT "端口起始范围";',
'SELECT "Column \`port_sta\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- node 表:添加 port_end 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'port_end'
),
'ALTER TABLE \`node\` ADD COLUMN \`port_end\` INT(10) DEFAULT 65535 COMMENT "端口结束范围";',
'SELECT "Column \`port_end\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为现有节点设置默认端口范围
UPDATE \`node\`
SET \`port_sta\` = 1000, \`port_end\` = 65535
WHERE \`port_sta\` IS NULL OR \`port_end\` IS NULL;
-- tunnel 表:删除废弃字段(如果存在)
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'in_port_sta'
),
'ALTER TABLE \`tunnel\` DROP COLUMN \`in_port_sta\`;',
'SELECT "Column \`in_port_sta\` not exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'in_port_end'
),
'ALTER TABLE \`tunnel\` DROP COLUMN \`in_port_end\`;',
'SELECT "Column \`in_port_end\` not exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'out_ip_sta'
),
'ALTER TABLE \`tunnel\` DROP COLUMN \`out_ip_sta\`;',
'SELECT "Column \`out_ip_sta\` not exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'out_ip_end'
),
'ALTER TABLE \`tunnel\` DROP COLUMN \`out_ip_end\`;',
'SELECT "Column \`out_ip_end\` not exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- tunnel 表:添加 tcp_listen_addr、udp_listen_addr、protocol(如果不存在)
-- tcp_listen_addr
@@ -85,28 +85,4 @@ public class ForwardWithTunnelDto {
private Long outFlow;
private String strategy;
// /**
// * 入口端口开始
// */
// private Integer inPortSta;
//
// /**
// * 入口端口结束
// */
// private Integer inPortEnd;
//
// /**
// * 出口IP
// */
// private String outIp;
//
// /**
// * 出口端口开始
// */
// private Integer outIpSta;
//
// /**
// * 出口端口结束
// */
// private Integer outIpEnd;
}
@@ -18,4 +18,14 @@ public class NodeDto {
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
@NotNull(message = "起始端口不能为空")
@Min(value = 1, message = "起始端口必须大于0")
@Max(value = 65535, message = "起始端口不能超过65535")
private Integer portSta;
@NotNull(message = "结束端口不能为空")
@Min(value = 1, message = "结束端口必须大于0")
@Max(value = 65535, message = "结束端口不能超过65535")
private Integer portEnd;
}
@@ -4,6 +4,8 @@ import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
@Data
public class NodeUpdateDto {
@@ -19,4 +21,14 @@ public class NodeUpdateDto {
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
@NotNull(message = "起始端口不能为空")
@Min(value = 1, message = "起始端口必须大于0")
@Max(value = 65535, message = "起始端口不能超过65535")
private Integer portSta;
@NotNull(message = "结束端口不能为空")
@Min(value = 1, message = "结束端口必须大于0")
@Max(value = 65535, message = "结束端口不能超过65535")
private Integer portEnd;
}
@@ -17,30 +17,10 @@ public class TunnelDto {
@NotNull(message = "入口节点不能为空")
private Long inNodeId;
@NotNull(message = "入口端口开始不能为空")
@Min(value = 1, message = "入口端口开始必须大于0")
@Max(value = 65535, message = "入口端口开始不能超过65535")
private Integer inPortSta;
@NotNull(message = "入口端口结束不能为空")
@Min(value = 1, message = "入口端口结束必须大于0")
@Max(value = 65535, message = "入口端口结束不能超过65535")
private Integer inPortEnd;
// 出口节点ID,当type=1时可以为空,会自动设置为入口节点ID
private Long outNodeId;
// 出口端口开始,当type=1时可以为空,会自动设置为入口端口
@Min(value = 0, message = "出口端口开始必须大于等于1")
@Max(value = 65535, message = "出口端口开始不能超过65535")
private Integer outIpSta;
// 出口端口结束,当type=1时可以为空,会自动设置为入口端口
@Min(value = 0, message = "出口端口结束必须大于等于1")
@Max(value = 65535, message = "出口端口结束不能超过65535")
private Integer outIpEnd;
@NotNull(message = "隧道类型不能为空")
private Integer type;
@@ -15,30 +15,15 @@ public class TunnelListDto {
private String ip;
/**
* 入口端口范围开始
* 入口节点端口起始范围
*/
private Integer inPortSta;
private Integer inNodePortSta;
/**
* 入口端口范围结束
* 入口节点端口结束范围
*/
private Integer inPortEnd;
/**
* 出口IP
*/
private String outIp;
/**
* 出口端口范围开始
*/
private Integer outIpSta;
/**
* 出口端口范围结束
*/
private Integer outIpEnd;
private Integer inNodePortEnd;
/**
* 隧道类型(1-端口转发,2-隧道转发)
*/
@@ -25,27 +25,7 @@ public class TunnelUpdateDto {
@DecimalMin(value = "0.0", message = "流量倍率不能小于0.0")
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
private BigDecimal trafficRatio;
@NotNull(message = "入口端口开始不能为空")
@Min(value = 1, message = "入口端口开始必须大于0")
@Max(value = 65535, message = "入口端口开始不能超过65535")
private Integer inPortSta;
@NotNull(message = "入口端口结束不能为空")
@Min(value = 1, message = "入口端口结束必须大于0")
@Max(value = 65535, message = "入口端口结束不能超过65535")
private Integer inPortEnd;
// 出口端口开始(对于隧道转发类型的隧道)
@Min(value = 1, message = "出口端口开始必须大于等于0")
@Max(value = 65535, message = "出口端口开始不能超过65535")
private Integer outIpSta;
// 出口端口结束(对于隧道转发类型的隧道)
@Min(value = 1, message = "出口端口结束必须大于等于0")
@Max(value = 65535, message = "出口端口结束不能超过65535")
private Integer outIpEnd;
// TCP监听地址
private String tcpListenAddr;
@@ -84,39 +84,5 @@ public class UserTunnelWithDetailDto {
private Long outFlow;
private Integer status;
// /**
// * 入口IP
// */
// private String inIp;
//
// /**
// * 入口端口开始
// */
// private Integer inPortSta;
//
// /**
// * 入口端口结束
// */
// private Integer inPortEnd;
//
// /**
// * 出口IP
// */
// private String outIp;
//
// /**
// * 出口端口开始
// */
// private Integer outIpSta;
//
// /**
// * 出口端口结束
// */
// private Integer outIpEnd;
//
// /**
// * 隧道类型(1-端口转发,2-隧道转发)
// */
// private Integer type;
}
@@ -34,11 +34,13 @@ public class WebSocketInterceptor extends HttpSessionHandshakeInterceptor {
ServletServerHttpRequest serverHttpRequest = (ServletServerHttpRequest) request;
String secret = serverHttpRequest.getServletRequest().getParameter("secret");
String type = serverHttpRequest.getServletRequest().getParameter("type");
String version = serverHttpRequest.getServletRequest().getParameter("version");
if (Objects.equals(type, "1")) {
Node node = nodeService.getOne(new QueryWrapper<Node>().eq("secret", secret));
if (node == null) return false;
attributes.put("id", node.getId());
node.setStatus(1);
node.setVersion(version);
nodeService.updateById(node);
}else {
boolean b = JwtUtil.validateToken(secret);
@@ -26,4 +26,10 @@ public class Node extends BaseEntity {
private String serverIp;
private String version;
private Integer portSta;
private Integer portEnd;
}
@@ -34,16 +34,6 @@ public class Tunnel extends BaseEntity {
*/
private String inIp;
/**
* 入口端口开始
*/
private Integer inPortSta;
/**
* 入口端口结束
*/
private Integer inPortEnd;
/**
* 出口节点ID
*/
@@ -54,16 +44,6 @@ public class Tunnel extends BaseEntity {
*/
private String outIp;
/**
* 出口端口开始
*/
private Integer outIpSta;
/**
* 出口端口结束
*/
private Integer outIpEnd;
/**
* 隧道类型(1-端口转发,2-隧道转发)
*/
File diff suppressed because it is too large Load Diff
@@ -54,6 +54,12 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
/** 隧道使用检查相关消息 */
private static final String ERROR_IN_NODE_IN_USE = "该节点还有 %d 个隧道作为入口节点在使用,请先删除相关隧道";
private static final String ERROR_OUT_NODE_IN_USE = "该节点还有 %d 个隧道作为出口节点在使用,请先删除相关隧道";
/** 端口范围验证相关消息 */
private static final String ERROR_PORT_STA_REQUIRED = "起始端口不能为空";
private static final String ERROR_PORT_END_REQUIRED = "结束端口不能为空";
private static final String ERROR_PORT_RANGE_INVALID = "端口必须在1-65535范围内";
private static final String ERROR_PORT_ORDER_INVALID = "结束端口不能小于起始端口";
// ========== 依赖注入 ==========
@@ -188,6 +194,9 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
Node node = new Node();
BeanUtils.copyProperties(nodeDto, node);
// 验证端口范围
validatePortRange(node.getPortSta(), node.getPortEnd());
// 设置默认属性
node.setSecret(IdUtil.simpleUUID());
node.setStatus(NODE_STATUS_ACTIVE);
@@ -212,6 +221,12 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
node.setName(nodeUpdateDto.getName());
node.setIp(nodeUpdateDto.getIp());
node.setServerIp(nodeUpdateDto.getServerIp());
node.setPortSta(nodeUpdateDto.getPortSta());
node.setPortEnd(nodeUpdateDto.getPortEnd());
// 验证端口范围
validatePortRange(node.getPortSta(), node.getPortEnd());
node.setUpdatedTime(System.currentTimeMillis());
return node;
}
@@ -386,4 +401,33 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
long colonCount = address.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
/**
* 验证端口范围的有效性
*
* @param portSta 起始端口
* @param portEnd 结束端口
* @throws RuntimeException 当端口范围无效时抛出异常
*/
private void validatePortRange(Integer portSta, Integer portEnd) {
// 检查起始端口是否为空
if (portSta == null) {
throw new RuntimeException(ERROR_PORT_STA_REQUIRED);
}
// 检查结束端口是否为空
if (portEnd == null) {
throw new RuntimeException(ERROR_PORT_END_REQUIRED);
}
// 检查端口范围是否在有效区间内
if (portSta < 1 || portSta > 65535 || portEnd < 1 || portEnd > 65535) {
throw new RuntimeException(ERROR_PORT_RANGE_INVALID);
}
// 检查端口顺序是否正确
if (portEnd < portSta) {
throw new RuntimeException(ERROR_PORT_ORDER_INVALID);
}
}
}
@@ -181,25 +181,11 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return nameValidationResult;
}
// 3. 验证端口范围
R portValidationResult = validatePortRanges(tunnelUpdateDto);
if (portValidationResult.getCode() != 0) {
return portValidationResult;
}
// 4. 验证隧道转发类型的出口端口
if (existingTunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) {
R tunnelTypeValidationResult = validateTunnelForwardUpdate(tunnelUpdateDto);
if (tunnelTypeValidationResult.getCode() != 0) {
return tunnelTypeValidationResult;
}
}
// 5. 更新允许修改的字段
existingTunnel.setName(tunnelUpdateDto.getName());
existingTunnel.setFlow(tunnelUpdateDto.getFlow());
existingTunnel.setInPortSta(tunnelUpdateDto.getInPortSta());
existingTunnel.setInPortEnd(tunnelUpdateDto.getInPortEnd());
// 更新流量倍率
if (tunnelUpdateDto.getTrafficRatio() != null) {
@@ -213,14 +199,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
if (StrUtil.isNotBlank(tunnelUpdateDto.getUdpListenAddr())) {
existingTunnel.setUdpListenAddr(tunnelUpdateDto.getUdpListenAddr());
}
if (existingTunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) {
existingTunnel.setOutIpSta(tunnelUpdateDto.getOutIpSta());
existingTunnel.setOutIpEnd(tunnelUpdateDto.getOutIpEnd());
}else{
existingTunnel.setOutIpSta(tunnelUpdateDto.getInPortSta());
existingTunnel.setOutIpEnd(tunnelUpdateDto.getInPortEnd());
}
// 6. 保存更新
boolean result = this.updateById(existingTunnel);
@@ -315,51 +294,11 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return R.ok();
}
/**
* 验证端口范围
*
* @param tunnelUpdateDto 更新数据传输对象
* @return 验证结果响应
*/
private R validatePortRanges(TunnelUpdateDto tunnelUpdateDto) {
// 验证入口端口范围
if (tunnelUpdateDto.getInPortSta() > tunnelUpdateDto.getInPortEnd()) {
return R.err(ERROR_IN_PORT_RANGE_INVALID);
}
// 验证出口端口范围(如果有的话)
if (tunnelUpdateDto.getOutIpSta() != null && tunnelUpdateDto.getOutIpEnd() != null) {
if (tunnelUpdateDto.getOutIpSta() > tunnelUpdateDto.getOutIpEnd()) {
return R.err(ERROR_OUT_PORT_RANGE_INVALID);
}
}
return R.ok();
}
/**
* 验证隧道转发更新时的出口端口
*
* @param tunnelUpdateDto 更新数据传输对象
* @return 验证结果响应
*/
private R validateTunnelForwardUpdate(TunnelUpdateDto tunnelUpdateDto) {
// 验证出口端口参数不能为空
if (tunnelUpdateDto.getOutIpSta() == null || tunnelUpdateDto.getOutIpEnd() == null) {
return R.err(ERROR_OUT_PORT_REQUIRED);
}
// 验证出口端口范围
if (tunnelUpdateDto.getOutIpSta() > tunnelUpdateDto.getOutIpEnd()) {
return R.err(ERROR_OUT_PORT_RANGE_INVALID);
}
return R.ok();
}
/**
* 验证隧道转发创建时的必要参数
*
*
* @param tunnelDto 隧道创建数据传输对象
* @return 验证结果响应
*/
@@ -368,17 +307,6 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
if (tunnelDto.getOutNodeId() == null) {
return R.err(ERROR_OUT_NODE_REQUIRED);
}
// 验证出口端口参数不能为空
if (tunnelDto.getOutIpSta() == null || tunnelDto.getOutIpEnd() == null) {
return R.err(ERROR_OUT_PORT_REQUIRED);
}
// 验证出口端口范围
if (tunnelDto.getOutIpSta() > tunnelDto.getOutIpEnd()) {
return R.err(ERROR_OUT_PORT_RANGE_INVALID);
}
return R.ok();
}
@@ -400,11 +328,6 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return NodeValidationResult.error(ERROR_IN_NODE_OFFLINE);
}
// 验证入口端口范围
if (tunnelDto.getInPortSta() > tunnelDto.getInPortEnd()) {
return NodeValidationResult.error(ERROR_IN_PORT_RANGE_INVALID);
}
return NodeValidationResult.success(inNode);
}
@@ -479,8 +402,6 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
private R setupPortForwardOutParameters(Tunnel tunnel, TunnelDto tunnelDto, String server_ip) {
tunnel.setOutNodeId(tunnelDto.getInNodeId());
tunnel.setOutIp(server_ip);
tunnel.setOutIpSta(tunnelDto.getInPortSta());
tunnel.setOutIpEnd(tunnelDto.getInPortEnd());
return R.ok();
}
@@ -519,15 +440,6 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
if (outNode.getStatus() != NODE_STATUS_ONLINE) {
return R.err(ERROR_OUT_NODE_OFFLINE);
}
// 验证出口端口参数
if (tunnelDto.getOutIpSta() == null || tunnelDto.getOutIpEnd() == null) {
return R.err(ERROR_OUT_PORT_REQUIRED);
}
if (tunnelDto.getOutIpSta() > tunnelDto.getOutIpEnd()) {
return R.err(ERROR_OUT_PORT_RANGE_INVALID);
}
// 设置出口参数
tunnel.setOutNodeId(tunnelDto.getOutNodeId());
tunnel.setOutIp(outNode.getServerIp());
@@ -684,13 +596,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
dto.setId(tunnel.getId().intValue());
dto.setName(tunnel.getName());
dto.setIp(tunnel.getInIp());
dto.setInPortSta(tunnel.getInPortSta());
dto.setInPortEnd(tunnel.getInPortEnd());
dto.setOutIp(tunnel.getOutIp());
dto.setOutIpSta(tunnel.getOutIpSta());
dto.setOutIpEnd(tunnel.getOutIpEnd());
dto.setType(tunnel.getType());
dto.setProtocol(tunnel.getProtocol());
// 获取入口节点的端口范围信息
if (tunnel.getInNodeId() != null) {
Node inNode = nodeService.getById(tunnel.getInNodeId());
if (inNode != null) {
dto.setInNodePortSta(inNode.getPortSta());
dto.setInNodePortEnd(inNode.getPortEnd());
}
}
return dto;
}
@@ -21,11 +21,7 @@
f.out_flow AS outFlow,
t.name AS tunnelName,
t.in_ip AS inIp,
t.in_port_sta AS inPortSta,
t.in_port_end AS inPortEnd,
t.out_ip AS outIp,
t.out_ip_sta AS outIpSta,
t.out_ip_end AS outIpEnd,
t.type,
t.protocol
FROM
@@ -55,11 +51,7 @@
f.strategy AS strategy,
t.name AS tunnelName,
t.in_ip AS inIp,
t.in_port_sta AS inPortSta,
t.in_port_end AS inPortEnd,
t.out_ip AS outIp,
t.out_ip_sta AS outIpSta,
t.out_ip_end AS outIpEnd,
t.type,
t.protocol
FROM
@@ -19,11 +19,7 @@
t.name as tunnelName,
t.flow as tunnelFlow,
t.in_ip as inIp,
t.in_port_sta as inPortSta,
t.in_port_end as inPortEnd,
t.out_ip as outIp,
t.out_ip_sta as outIpSta,
t.out_ip_end as outIpEnd,
t.type,
t.protocol,
sl.name as speedLimitName,
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE=http://localhost:6365
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE=
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
pnpm-lock.yaml
yarn.lock
package-lock.json
bun.lockb
+1
View File
@@ -0,0 +1 @@
package-lock=true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Next UI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+50
View File
@@ -0,0 +1,50 @@
# Vite & HeroUI Template
This is a template for creating applications using Vite and HeroUI (v2).
[Try it on CodeSandbox](https://githubbox.com/frontio-ai/vite-template)
## Technologies Used
- [Vite](https://vitejs.dev/guide/)
- [HeroUI](https://heroui.com)
- [Tailwind CSS](https://tailwindcss.com)
- [Tailwind Variants](https://tailwind-variants.org)
- [TypeScript](https://www.typescriptlang.org)
- [Framer Motion](https://www.framer.com/motion)
## How to Use
To clone the project, run the following command:
```bash
git clone https://github.com/frontio-ai/vite-template.git
```
### Install dependencies
You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`:
```bash
npm install
```
### Run the development server
```bash
npm run dev
```
### Setup pnpm (optional)
If you are using `pnpm`, you need to add the following code to your `.npmrc` file:
```bash
public-hoist-pattern[]=*@heroui/*
```
After modifying the `.npmrc` file, you need to run `pnpm install` again to ensure that the dependencies are installed correctly.
## License
Licensed under the [MIT license](https://github.com/frontio-ai/vite-template/blob/main/LICENSE).
+175
View File
@@ -0,0 +1,175 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, globalIgnores } from "eslint/config";
import { fixupConfigRules, fixupPluginRules } from "@eslint/compat";
import react from "eslint-plugin-react";
import unusedImports from "eslint-plugin-unused-imports";
import _import from "eslint-plugin-import";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import jsxA11Y from "eslint-plugin-jsx-a11y";
import prettier from "eslint-plugin-prettier";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([
globalIgnores([
".now/*",
"**/*.css",
"**/.changeset",
"**/dist",
"esm/*",
"public/*",
"tests/*",
"scripts/*",
"**/*.config.js",
"**/.DS_Store",
"**/node_modules",
"**/coverage",
"**/.next",
"**/build",
"!**/.commitlintrc.cjs",
"!**/.lintstagedrc.cjs",
"!**/jest.config.js",
"!**/plopfile.js",
"!**/react-shim.js",
"!**/tsup.config.ts",
]),
{
extends: fixupConfigRules(
compat.extends(
"plugin:react/recommended",
"plugin:prettier/recommended",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
),
),
plugins: {
react: fixupPluginRules(react),
"unused-imports": unusedImports,
import: fixupPluginRules(_import),
"@typescript-eslint": typescriptEslint,
"jsx-a11y": fixupPluginRules(jsxA11Y),
prettier: fixupPluginRules(prettier),
},
languageOptions: {
globals: {
...Object.fromEntries(
Object.entries(globals.browser).map(([key]) => [key, "off"]),
),
...globals.node,
},
parser: tsParser,
ecmaVersion: 12,
sourceType: "module",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: "detect",
},
},
files: ["**/*.ts", "**/*.tsx"],
rules: {
"no-console": "warn",
"react/prop-types": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"jsx-a11y/click-events-have-key-events": "warn",
"jsx-a11y/interactive-supports-focus": "warn",
"prettier/prettier": "warn",
"no-unused-vars": "off",
"unused-imports/no-unused-vars": "off",
"unused-imports/no-unused-imports": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{
args: "after-used",
ignoreRestSiblings: false,
argsIgnorePattern: "^_.*?$",
},
],
"import/order": [
"warn",
{
groups: [
"type",
"builtin",
"object",
"external",
"internal",
"parent",
"sibling",
"index",
],
pathGroups: [
{
pattern: "~/**",
group: "external",
position: "after",
},
],
"newlines-between": "always",
},
],
"react/self-closing-comp": "warn",
"react/jsx-sort-props": [
"warn",
{
callbacksLast: true,
shorthandFirst: true,
noSortAlphabetically: false,
reservedFirst: true,
},
],
"padding-line-between-statements": [
"warn",
{
blankLine: "always",
prev: "*",
next: "return",
},
{
blankLine: "always",
prev: ["const", "let", "var"],
next: "*",
},
{
blankLine: "any",
prev: ["const", "let", "var"],
next: ["const", "let", "var"],
},
],
},
},
]);
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GOST 管理后台</title>
<script>
// 防止暗黑模式闪烁:在页面加载前立即应用主题
(function() {
const theme = localStorage.getItem('heroui-theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
} else {
document.documentElement.classList.remove('dark');
document.documentElement.style.colorScheme = 'light';
}
})();
</script>
<style>
/* 防止页面闪烁的基础样式 */
:root {
--toaster-bg: #ffffff;
--toaster-color: #000000;
--toaster-border: #e5e7eb;
}
html.dark {
--toaster-bg: #374151;
--toaster-color: #ffffff;
--toaster-border: #4b5563;
}
body {
margin: 0;
padding: 0;
background-color: #f8fafc;
transition: background-color 0.2s ease;
}
html.dark body {
background-color: #1f2937;
}
#root {
min-height: 100vh;
}
</style>
<meta key="title" content="GOST 管理后台" property="og:title" />
<meta
content="GOST 代理管理系统 - 现代化的后台管理界面"
property="og:description"
/>
<meta
content="GOST 代理管理系统 - 现代化的后台管理界面"
name="description"
/>
<meta
key="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
name="viewport"
/>
<link href="/favicon.ico" rel="icon" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
{
"name": "vite-template",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint --fix",
"preview": "vite preview"
},
"dependencies": {
"@heroui/alert": "^2.2.22",
"@heroui/avatar": "^2.2.18",
"@heroui/button": "2.2.22",
"@heroui/card": "^2.2.21",
"@heroui/chip": "^2.2.18",
"@heroui/divider": "^2.2.15",
"@heroui/dropdown": "^2.3.22",
"@heroui/input": "2.4.22",
"@heroui/link": "2.2.19",
"@heroui/modal": "^2.2.19",
"@heroui/navbar": "2.2.20",
"@heroui/select": "^2.4.22",
"@heroui/spacer": "^2.2.16",
"@heroui/spinner": "^2.2.19",
"@heroui/switch": "2.2.20",
"@heroui/system": "2.4.18",
"@heroui/table": "^2.2.21",
"@heroui/theme": "2.4.17",
"@heroui/use-theme": "2.1.9",
"@react-aria/visually-hidden": "3.8.25",
"@react-types/shared": "3.30.0",
"axios": "^1.10.0",
"clsx": "2.1.1",
"framer-motion": "11.15.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"react-router-dom": "6.23.0",
"tailwind-variants": "0.3.0",
"tailwindcss": "3.4.16"
},
"devDependencies": {
"@eslint/compat": "1.2.8",
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.25.1",
"@types/node": "20.5.7",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"@vitejs/plugin-react": "4.4.1",
"autoprefixer": "10.4.21",
"eslint": "9.25.1",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-unused-imports": "4.1.4",
"globals": "16.0.0",
"postcss": "8.5.3",
"prettier": "3.5.3",
"typescript": "5.6.3",
"vite": "5.2.0",
"vite-tsconfig-paths": "4.3.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+121
View File
@@ -0,0 +1,121 @@
import { Route, Routes, Navigate, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import IndexPage from "@/pages/index";
import ChangePasswordPage from "@/pages/change-password";
import DashboardPage from "@/pages/dashboard";
import ForwardPage from "@/pages/forward";
import LimitPage from "@/pages/limit";
import PageWrapper from "@/components/page-wrapper";
import { isLoggedIn } from "@/utils/auth";
// 路由保护组件 - 使用useNavigate避免循环渲染
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const [checking, setChecking] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const checkAuth = () => {
const loggedIn = isLoggedIn();
if (!loggedIn) {
// 如果未登录,直接导航到登录页
navigate('/', { replace: true });
return;
}
setAuthenticated(true);
setChecking(false);
};
checkAuth();
}, [navigate]);
if (checking) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">检查登陆状态...</div>
</div>
);
}
return authenticated ? <>{children}</> : null;
};
function App() {
return (
<Routes>
<Route
path="/"
element={
isLoggedIn() ? <Navigate to="/dashboard" replace /> : <IndexPage />
}
/>
<Route
path="/change-password"
element={
<ProtectedRoute>
<ChangePasswordPage />
</ProtectedRoute>
}
/>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/forward"
element={
<ProtectedRoute>
<ForwardPage />
</ProtectedRoute>
}
/>
<Route
path="/tunnel"
element={
<ProtectedRoute>
<PageWrapper title="隧道管理" description="此页面正在开发中...">
<div></div>
</PageWrapper>
</ProtectedRoute>
}
/>
<Route
path="/node"
element={
<ProtectedRoute>
<PageWrapper title="节点监控" description="此页面正在开发中...">
<div></div>
</PageWrapper>
</ProtectedRoute>
}
/>
<Route
path="/user"
element={
<ProtectedRoute>
<PageWrapper title="用户管理" description="此页面正在开发中...">
<div></div>
</PageWrapper>
</ProtectedRoute>
}
/>
<Route
path="/limit"
element={
<ProtectedRoute>
<LimitPage />
</ProtectedRoute>
}
/>
</Routes>
);
}
export default App;
+66
View File
@@ -0,0 +1,66 @@
import Network from './network';
// 登陆相关接口
export interface LoginData {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
role_id: number;
name: string;
requirePasswordChange?: boolean;
}
export const login = (data: LoginData) => Network.post<LoginResponse>("/user/login", data);
// 用户CRUD操作 - 全部使用POST请求
export const createUser = (data: any) => Network.post("/user/create", data);
export const getAllUsers = (pageData: any = {}) => Network.post("/user/list", pageData);
export const updateUser = (data: any) => Network.post("/user/update", data);
export const deleteUser = (id: number) => Network.post("/user/delete", { id });
export const getUserPackageInfo = () => Network.post("/user/package");
// 节点CRUD操作 - 全部使用POST请求
export const createNode = (data: any) => Network.post("/node/create", data);
export const getNodeList = () => Network.post("/node/list");
export const updateNode = (data: any) => Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id });
// 隧道CRUD操作 - 全部使用POST请求
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
export const getTunnelList = () => Network.post("/tunnel/list");
export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id });
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
// 用户隧道权限管理操作 - 全部使用POST请求
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
export const getUserTunnelList = (queryData: any = {}) => Network.post("/tunnel/user/list", queryData);
export const removeUserTunnel = (params: any) => Network.post("/tunnel/user/remove", params);
export const updateUserTunnelFlow = (params: any) => Network.post("/tunnel/user/updateFlow", params);
export const updateUserTunnel = (data: any) => Network.post("/tunnel/user/update", data);
export const userTunnel = () => Network.post("/tunnel/user/tunnel");
// 转发CRUD操作 - 全部使用POST请求
export const createForward = (data: any) => Network.post("/forward/create", data);
export const getForwardList = () => Network.post("/forward/list");
export const updateForward = (data: any) => Network.post("/forward/update", data);
export const deleteForward = (id: number) => Network.post("/forward/delete", { id });
export const forceDeleteForward = (id: number) => Network.post("/forward/force-delete", { id });
// 转发服务控制操作 - 通过Java后端接口
export const pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId });
export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { id: forwardId });
// 限速规则CRUD操作 - 全部使用POST请求
export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data);
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
export const updateSpeedLimit = (data: any) => Network.post("/speed-limit/update", data);
export const deleteSpeedLimit = (id: number) => Network.post("/speed-limit/delete", { id });
// 修改密码接口
export const updatePassword = (data: any) => Network.post("/user/updatePassword", data);
+106
View File
@@ -0,0 +1,106 @@
import axios, { AxiosResponse } from 'axios';
// 配置axios基础URL
const baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/';
axios.defaults.baseURL = baseURL;
// 在开发环境下输出API配置信息
if (import.meta.env.DEV) {
console.log('🌐 API Configuration:');
console.log(' - VITE_API_BASE:', import.meta.env.VITE_API_BASE || '(undefined)');
console.log(' - Base URL:', baseURL);
console.log(' - Environment:', import.meta.env.MODE);
}
interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 处理token失效的逻辑
function handleTokenExpired() {
// 清除localStorage中的token
window.localStorage.removeItem('token');
window.localStorage.removeItem('role_id');
window.localStorage.removeItem('name');
// 跳转到登录页面
if (window.location.pathname !== '/') {
window.location.href = '/';
}
}
// 检查响应是否为token失效
function isTokenExpired(response: ApiResponse) {
return response && response.code === 401 &&
(response.msg === '未登录或token已过期' ||
response.msg === '无效的token或token已过期' ||
response.msg === '无法获取用户权限信息');
}
const Network = {
get: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
return new Promise(function(resolve) {
axios.get(path, {
params: data,
timeout: 30000,
headers: {
"Authorization": window.localStorage.getItem('token')
}
})
.then(function(response: AxiosResponse<ApiResponse<T>>) {
// 检查是否token失效
if (isTokenExpired(response.data)) {
handleTokenExpired();
return;
}
resolve(response.data);
})
.catch(function(error: any) {
console.error('GET请求错误:', error);
// 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) {
handleTokenExpired();
return;
}
resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T});
});
});
},
post: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
return new Promise(function(resolve) {
axios.post(path, data, {
timeout: 30000,
headers: {
"Authorization": window.localStorage.getItem('token'),
"Content-Type": "application/json"
}
})
.then(function(response: AxiosResponse<ApiResponse<T>>) {
// 检查是否token失效
if (isTokenExpired(response.data)) {
handleTokenExpired();
return;
}
resolve(response.data);
})
.catch(function(error: any) {
console.error('POST请求错误:', error);
// 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) {
handleTokenExpired();
return;
}
resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T});
});
});
}
};
export default Network;
+68
View File
@@ -0,0 +1,68 @@
import * as React from "react";
import { IconSvgProps } from "@/types";
export const Logo: React.FC<IconSvgProps> = ({
size = 36,
height,
...props
}) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 32 32"
width={size || height}
{...props}
>
<path
clipRule="evenodd"
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
);
export const MoonFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z"
fill="currentColor"
/>
</svg>
);
export const SunFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g fill="currentColor">
<path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
<path d="M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z" />
</g>
</svg>
);
+32
View File
@@ -0,0 +1,32 @@
import { Link } from "@heroui/link";
import {
Navbar as HeroUINavbar,
NavbarBrand,
NavbarContent,
} from "@heroui/navbar";
import { ThemeSwitch } from "@/components/theme-switch";
import { Logo } from "@/components/icons";
export const Navbar = () => {
return (
<HeroUINavbar maxWidth="xl" position="sticky">
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
<NavbarBrand className="gap-3 max-w-fit">
<Link
className="flex justify-start items-center gap-1"
color="foreground"
href="/"
>
<Logo />
<p className="font-bold text-inherit">GOST</p>
</Link>
</NavbarBrand>
</NavbarContent>
<NavbarContent className="basis-1/5 sm:basis-full" justify="end">
<ThemeSwitch />
</NavbarContent>
</HeroUINavbar>
);
};
@@ -0,0 +1,56 @@
import React, { useState, useEffect } from 'react';
import AdminLayout from '@/layouts/admin';
interface PageWrapperProps {
children: React.ReactNode;
title: string;
description?: string;
className?: string;
}
export default function PageWrapper({
children,
title,
description,
className = "container mx-auto max-w-7xl px-3 lg:px-6 py-8"
}: PageWrapperProps) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// 使用短暂的延迟确保组件完全加载,避免闪烁
const timer = setTimeout(() => {
setIsReady(true);
}, 50);
return () => clearTimeout(timer);
}, []);
if (!isReady) {
return (
<AdminLayout>
<div className="container mx-auto max-w-7xl px-3 lg:px-6 py-8">
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full"></div>
<span className="text-default-600">正在加载页面...</span>
</div>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className={className}>
<div className="mb-6">
<h1 className="text-2xl font-bold mb-2 text-foreground">{title}</h1>
{description && (
<p className="text-default-600">{description}</p>
)}
</div>
{children}
</div>
</AdminLayout>
);
}
@@ -0,0 +1,41 @@
import { tv } from "tailwind-variants";
export const title = tv({
base: "tracking-tight inline font-semibold",
variants: {
color: {
violet: "from-[#FF1CF7] to-[#b249f8]",
yellow: "from-[#FF705B] to-[#FFB457]",
blue: "from-[#5EA2EF] to-[#0072F5]",
cyan: "from-[#00b7fa] to-[#01cfea]",
green: "from-[#6FEE8D] to-[#17c964]",
pink: "from-[#FF72E1] to-[#F54C7A]",
foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]",
},
size: {
sm: "text-3xl lg:text-4xl",
md: "text-[2.3rem] lg:text-5xl leading-9",
lg: "text-4xl lg:text-6xl",
},
fullWidth: {
true: "w-full block",
},
},
defaultVariants: {
size: "md",
},
compoundVariants: [
{
color: [
"violet",
"yellow",
"blue",
"cyan",
"green",
"pink",
"foreground",
],
class: "bg-clip-text text-transparent bg-gradient-to-b",
},
],
});
@@ -0,0 +1,36 @@
import React, { useEffect } from 'react';
import { useTheme } from '@heroui/use-theme';
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { theme, setTheme } = useTheme();
useEffect(() => {
// 确保主题与HTML class同步
const updateThemeClass = (currentTheme: string) => {
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
} else {
document.documentElement.classList.remove('dark');
document.documentElement.style.colorScheme = 'light';
}
// 保存到localStorage
localStorage.setItem('heroui-theme', currentTheme);
};
// 初始化时检查localStorage
const savedTheme = localStorage.getItem('heroui-theme');
if (savedTheme && savedTheme !== theme) {
setTheme(savedTheme);
}
// 监听主题变化
updateThemeClass(theme);
}, [theme, setTheme]);
return <>{children}</>;
};
@@ -0,0 +1,82 @@
import { FC, useState, useEffect } from "react";
import { VisuallyHidden } from "@react-aria/visually-hidden";
import { SwitchProps, useSwitch } from "@heroui/switch";
import clsx from "clsx";
import { useTheme } from "@heroui/use-theme";
import { SunFilledIcon, MoonFilledIcon } from "@/components/icons";
export interface ThemeSwitchProps {
className?: string;
classNames?: SwitchProps["classNames"];
}
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
className,
classNames,
}) => {
const [isMounted, setIsMounted] = useState(false);
const { theme, setTheme } = useTheme();
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps,
} = useSwitch({
isSelected: theme === "light",
onChange: () => setTheme(theme === "light" ? "dark" : "light"),
});
useEffect(() => {
setIsMounted(true);
}, [isMounted]);
// Prevent Hydration Mismatch
if (!isMounted) return <div className="w-6 h-6" />;
return (
<Component
aria-label={isSelected ? "Switch to dark mode" : "Switch to light mode"}
{...getBaseProps({
className: clsx(
"px-px transition-opacity hover:opacity-80 cursor-pointer",
className,
classNames?.base,
),
})}
>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: clsx(
[
"w-auto h-auto",
"bg-transparent",
"rounded-lg",
"flex items-center justify-center",
"group-data-[selected=true]:bg-transparent",
"!text-default-500",
"pt-px",
"px-0",
"mx-0",
],
classNames?.wrapper,
),
})}
>
{isSelected ? (
<MoonFilledIcon size={22} />
) : (
<SunFilledIcon size={22} />
)}
</div>
</Component>
);
};
+15
View File
@@ -0,0 +1,15 @@
export type SiteConfig = typeof siteConfig;
export const siteConfig = {
name: "GOST",
description: "GOST 代理管理系统",
navItems: [],
navMenuItems: [],
links: {
github: "https://github.com/go-gost/gost",
twitter: "",
docs: "",
discord: "",
sponsor: "",
},
};
+434
View File
@@ -0,0 +1,434 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { Button } from "@heroui/button";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from "@heroui/dropdown";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/modal";
import { Input } from "@heroui/input";
import { toast } from 'react-hot-toast';
import { Logo } from '@/components/icons';
import { ThemeSwitch } from '@/components/theme-switch';
import { updatePassword } from '@/api';
import { safeLogout } from '@/utils/logout';
interface MenuItem {
path: string;
label: string;
icon: React.ReactNode;
adminOnly?: boolean;
}
interface PasswordForm {
newUsername: string;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const navigate = useNavigate();
const location = useLocation();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [isMobile, setIsMobile] = useState(false);
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
const [username, setUsername] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordForm, setPasswordForm] = useState<PasswordForm>({
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
// 菜单项配置
const menuItems: MenuItem[] = [
{
path: '/dashboard',
label: '仪表板',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
)
},
{
path: '/forward',
label: '转发管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
)
},
{
path: '/tunnel',
label: '隧道管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
</svg>
),
adminOnly: true
},
{
path: '/node',
label: '节点监控',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
),
adminOnly: true
},
{
path: '/user',
label: '用户管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
adminOnly: true
},
{
path: '/limit',
label: '限速管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
),
adminOnly: true
}
];
// 检查移动端
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
if (window.innerWidth > 768) {
setMobileMenuVisible(false);
}
};
useEffect(() => {
// 获取用户信息
const name = localStorage.getItem('name') || 'Admin';
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
let adminFlag = localStorage.getItem('admin') === 'true';
if (localStorage.getItem('admin') === null) {
const roleId = parseInt(localStorage.getItem('role_id') || '1', 10);
adminFlag = roleId === 0;
// 补充设置admin字段,避免下次再次判断
localStorage.setItem('admin', adminFlag.toString());
}
setUsername(name);
setIsAdmin(adminFlag);
// 响应式检查
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
}, []);
// 退出登录
const handleLogout = () => {
safeLogout();
navigate('/');
};
// 切换移动端菜单
const toggleMobileMenu = () => {
setMobileMenuVisible(!mobileMenuVisible);
};
// 隐藏移动端菜单
const hideMobileMenu = () => {
setMobileMenuVisible(false);
};
// 菜单点击处理
const handleMenuClick = (path: string) => {
navigate(path);
if (isMobile) {
hideMobileMenu();
}
};
// 密码表单验证
const validatePasswordForm = (): boolean => {
if (!passwordForm.newUsername.trim()) {
toast.error('请输入新用户名');
return false;
}
if (passwordForm.newUsername.length < 3) {
toast.error('用户名长度至少3位');
return false;
}
if (!passwordForm.currentPassword) {
toast.error('请输入当前密码');
return false;
}
if (!passwordForm.newPassword) {
toast.error('请输入新密码');
return false;
}
if (passwordForm.newPassword.length < 6) {
toast.error('新密码长度不能少于6位');
return false;
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error('两次输入密码不一致');
return false;
}
return true;
};
// 提交密码修改
const handlePasswordSubmit = async () => {
if (!validatePasswordForm()) return;
setPasswordLoading(true);
try {
const response = await updatePassword(passwordForm);
if (response.code === 0) {
toast.success('密码修改成功,请重新登录');
onOpenChange();
handleLogout();
} else {
toast.error(response.msg || '密码修改失败');
}
} catch (error) {
toast.error('修改密码时发生错误');
console.error('修改密码错误:', error);
} finally {
setPasswordLoading(false);
}
};
// 重置密码表单
const resetPasswordForm = () => {
setPasswordForm({
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
};
// 过滤菜单项(根据权限)
const filteredMenuItems = menuItems.filter(item =>
!item.adminOnly || isAdmin
);
return (
<div className="flex h-screen bg-gray-50 dark:bg-black">
{/* 移动端遮罩层 */}
{isMobile && mobileMenuVisible && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={hideMobileMenu}
/>
)}
{/* 左侧菜单栏 */}
<aside className={`
${isMobile ? 'fixed' : 'relative'}
${isMobile && !mobileMenuVisible ? '-translate-x-full' : 'translate-x-0'}
${isMobile ? 'w-64' : 'w-64'}
bg-white dark:bg-black
shadow-lg
border-r border-gray-200 dark:border-gray-600
z-50
transition-transform duration-300 ease-in-out
flex flex-col
h-full
`}>
{/* Logo 区域 */}
<div className="px-4 py-3 h-14 flex items-center">
<div className="flex items-center gap-3">
<Logo size={28} />
<div>
<h1 className="text-base font-bold text-foreground">GOST 管理后台</h1>
<p className="text-xs text-default-500">v1.0.2</p>
</div>
</div>
</div>
{/* 菜单导航 */}
<nav className="flex-1 px-4 py-6">
<ul className="space-y-1">
{filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<li key={item.path}>
<button
onClick={() => handleMenuClick(item.path)}
className={`
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
transition-colors duration-200 min-h-[44px]
${isActive
? 'bg-primary-100 dark:bg-primary-600/20 text-primary-600 dark:text-primary-300'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900'
}
`}
>
<div className="flex-shrink-0">
{item.icon}
</div>
<span className="font-medium text-sm">{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
</aside>
{/* 主内容区域 */}
<div className="flex flex-col flex-1 overflow-hidden">
{/* 顶部导航栏 */}
<header className="bg-white dark:bg-black shadow-md border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 lg:px-6 relative z-10">
<div className="flex items-center gap-4">
{/* 移动端菜单按钮 */}
{isMobile && (
<Button
isIconOnly
variant="light"
onPress={toggleMobileMenu}
className="lg:hidden"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</Button>
)}
</div>
<div className="flex items-center gap-3">
<ThemeSwitch />
{/* 用户菜单 */}
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button variant="light" className="text-sm font-medium text-foreground">
{username}
<svg className="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="用户菜单">
<DropdownItem
key="change-password"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clipRule="evenodd" />
</svg>
}
onPress={onOpen}
>
修改密码
</DropdownItem>
<DropdownItem
key="logout"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" />
</svg>
}
className="text-danger"
color="danger"
onPress={handleLogout}
>
退出登录
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</header>
{/* 主内容 */}
<main className="flex-1 overflow-y-auto bg-gray-100 dark:bg-black">
{children}
</main>
</div>
{/* 修改密码弹窗 */}
<Modal
isOpen={isOpen}
onOpenChange={() => {
onOpenChange();
resetPasswordForm();
}}
size={isMobile ? 'full' : 'md'}
scrollBehavior="inside"
>
<ModalContent>
{(onClose: () => void) => (
<>
<ModalHeader className="flex flex-col gap-1">修改密码</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Input
label="新用户名"
placeholder="请输入新用户名(至少3位)"
value={passwordForm.newUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newUsername: e.target.value }))}
variant="bordered"
/>
<Input
label="当前密码"
type="password"
placeholder="请输入当前密码"
value={passwordForm.currentPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
variant="bordered"
/>
<Input
label="新密码"
type="password"
placeholder="请输入新密码(至少6位)"
value={passwordForm.newPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
variant="bordered"
/>
<Input
label="确认密码"
type="password"
placeholder="请再次输入新密码"
value={passwordForm.confirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
variant="bordered"
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="default" variant="light" onPress={onClose}>
取消
</Button>
<Button
color="primary"
onPress={handlePasswordSubmit}
isLoading={passwordLoading}
>
确定
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Link } from "@heroui/link";
import { Navbar } from "@/components/navbar";
export default function DefaultLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative flex flex-col h-screen">
<Navbar />
<main className="container mx-auto max-w-7xl px-6 flex-grow pt-16">
{children}
</main>
<footer className="w-full flex items-center justify-center py-3">
<Link
isExternal
className="flex items-center gap-1 text-current"
href="https://heroui.com"
title="heroui.com homepage"
>
</Link>
</footer>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx";
import { Provider } from "./provider.tsx";
import "@/styles/globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<Provider>
<App />
</Provider>
</BrowserRouter>
);
+200
View File
@@ -0,0 +1,200 @@
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from 'react-hot-toast';
import { title } from "@/components/primitives";
import { updatePassword } from "@/api";
import DefaultLayout from "@/layouts/default";
import { safeLogout } from "@/utils/logout";
interface PasswordForm {
newUsername: string;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export default function ChangePasswordPage() {
const [form, setForm] = useState<PasswordForm>({
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<PasswordForm>>({});
const navigate = useNavigate();
const validateForm = (): boolean => {
const newErrors: Partial<PasswordForm> = {};
if (!form.newUsername.trim()) {
newErrors.newUsername = '请输入新用户名';
} else if (form.newUsername.length < 3) {
newErrors.newUsername = '用户名长度至少3位';
} else if (form.newUsername.length > 20) {
newErrors.newUsername = '用户名长度不能超过20位';
}
if (!form.currentPassword.trim()) {
newErrors.currentPassword = '请输入当前密码';
}
if (!form.newPassword.trim()) {
newErrors.newPassword = '请输入新密码';
} else if (form.newPassword.length < 6) {
newErrors.newPassword = '新密码长度不能少于6位';
} else if (form.newPassword.length > 20) {
newErrors.newPassword = '新密码长度不能超过20位';
}
if (!form.confirmPassword.trim()) {
newErrors.confirmPassword = '请再次输入新密码';
} else if (form.confirmPassword !== form.newPassword) {
newErrors.confirmPassword = '两次输入密码不一致';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleInputChange = (field: keyof PasswordForm, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
const handleSubmit = async () => {
if (!validateForm()) return;
setLoading(true);
try {
const response = await updatePassword(form);
if (response.code === 0) {
toast.success(response.msg || '账号密码修改成功');
// 使用 toast 确认对话框的替代方案
setTimeout(() => {
toast.success('即将跳转到登陆页面,请重新登录');
setTimeout(() => {
logout();
}, 1000);
}, 1000);
} else {
toast.error(response.msg || '账号密码修改失败');
}
} catch (error) {
console.error('修改账号密码错误:', error);
toast.error('修改账号密码时发生错误');
} finally {
setLoading(false);
}
};
const logout = () => {
safeLogout();
navigate('/');
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) {
handleSubmit();
}
};
return (
<DefaultLayout>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10 min-h-[calc(100vh-200px)]">
<div className="w-full max-w-lg">
<Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-warning-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<h1 className={title({ size: "sm" })}>安全提醒</h1>
<p className="text-small text-default-500 mt-2 text-center">检测到您使用的是默认账号密码,为了您的账户安全,请立即修改</p>
</CardHeader>
<CardBody className="px-6 py-6">
<div className="flex flex-col gap-4">
<Input
label="新用户名"
placeholder="请输入新用户名(至少3位)"
value={form.newUsername}
onChange={(e) => handleInputChange('newUsername', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.newUsername}
errorMessage={errors.newUsername}
/>
<Input
label="当前密码"
placeholder="请输入当前密码"
type="password"
value={form.currentPassword}
onChange={(e) => handleInputChange('currentPassword', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.currentPassword}
errorMessage={errors.currentPassword}
/>
<Input
label="新密码"
placeholder="请输入新密码(至少6位)"
type="password"
value={form.newPassword}
onChange={(e) => handleInputChange('newPassword', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.newPassword}
errorMessage={errors.newPassword}
/>
<Input
label="确认新密码"
placeholder="请再次输入新密码"
type="password"
value={form.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.confirmPassword}
errorMessage={errors.confirmPassword}
/>
<Button
color="warning"
size="lg"
onClick={handleSubmit}
isLoading={loading}
disabled={loading}
className="mt-2"
>
{loading ? "修改中..." : "立即修改账号密码"}
</Button>
<div className="bg-warning-50 border border-warning-200 text-warning-700 px-3 py-2 rounded-lg text-sm text-center">
⚠️ 注意:修改账号密码后需要重新登录
</div>
</div>
</CardBody>
</Card>
</div>
</section>
</DefaultLayout>
);
}
+842
View File
@@ -0,0 +1,842 @@
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal";
import { useState, useEffect } from "react";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
import { getUserPackageInfo } from "@/api";
interface UserInfo {
flow: number;
inFlow: number;
outFlow: number;
num: number;
expTime?: string;
flowResetTime?: number;
}
interface UserTunnel {
id: number;
tunnelId: number;
tunnelName: string;
flow: number;
inFlow: number;
outFlow: number;
num: number;
expTime?: string;
flowResetTime?: number;
tunnelFlow: number;
}
interface Forward {
id: number;
name: string;
tunnelId: number;
tunnelName: string;
inIp: string;
inPort: number;
remoteAddr: string;
inFlow: number;
outFlow: number;
}
interface AddressItem {
id: number;
ip: string;
address: string;
copying: boolean;
}
export default function DashboardPage() {
const [loading, setLoading] = useState(true);
const [userInfo, setUserInfo] = useState<UserInfo>({} as UserInfo);
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]);
const [forwardList, setForwardList] = useState<Forward[]>([]);
const [addressModalOpen, setAddressModalOpen] = useState(false);
const [addressModalTitle, setAddressModalTitle] = useState('');
const [addressList, setAddressList] = useState<AddressItem[]>([]);
// 检查有效期通知
const checkExpirationNotifications = (userInfo: UserInfo, tunnels: UserTunnel[]) => {
// 避免重复通知,检查是否已经显示过
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map(t => t.expTime).join(',')}`;
const lastNotified = localStorage.getItem('lastNotified');
if (lastNotified === notificationKey) {
return; // 已经通知过,不重复显示
}
let hasNotification = false;
// 检查主账户有效期
if (userInfo.expTime) {
const expDate = new Date(userInfo.expTime);
const now = new Date();
if (!isNaN(expDate.getTime()) && expDate > now) {
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7 && diffDays > 0) {
hasNotification = true;
if (diffDays === 1) {
toast('账户将于明天过期,请及时续费', {
icon: '⚠️',
duration: 6000,
style: { background: '#f59e0b', color: '#fff' }
});
} else {
toast(`账户将于${diffDays}天后过期,请及时续费`, {
icon: '⚠️',
duration: 6000,
style: { background: '#f59e0b', color: '#fff' }
});
}
} else if (diffDays <= 0) {
hasNotification = true;
toast('账户已过期,请立即续费', {
icon: '⚠️',
duration: 8000,
style: { background: '#ef4444', color: '#fff' }
});
}
}
}
// 检查隧道有效期
tunnels.forEach(tunnel => {
if (tunnel.expTime) {
const expDate = new Date(tunnel.expTime);
const now = new Date();
if (!isNaN(expDate.getTime()) && expDate > now) {
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7 && diffDays > 0) {
hasNotification = true;
if (diffDays === 1) {
toast(`隧道"${tunnel.tunnelName}"将于明天过期`, {
icon: '⚠️',
duration: 5000,
style: { background: '#f59e0b', color: '#fff' }
});
} else {
toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, {
icon: '⚠️',
duration: 5000,
style: { background: '#f59e0b', color: '#fff' }
});
}
} else if (diffDays <= 0) {
hasNotification = true;
toast(`隧道"${tunnel.tunnelName}"已过期`, {
icon: '⚠️',
duration: 6000,
style: { background: '#ef4444', color: '#fff' }
});
}
}
}
});
// 如果显示了通知,记录防止重复
if (hasNotification) {
localStorage.setItem('lastNotified', notificationKey);
}
};
useEffect(() => {
// 重置状态并加载数据,防止页面切换时显示旧数据
setLoading(true);
setUserInfo({} as UserInfo);
setUserTunnels([]);
setForwardList([]);
loadPackageData();
localStorage.setItem('e', '/dashboard');
}, []);
const loadPackageData = async () => {
setLoading(true);
try {
const res = await getUserPackageInfo();
if (res.code === 0) {
const data = res.data;
setUserInfo(data.userInfo || {});
setUserTunnels(data.tunnelPermissions || []);
setForwardList(data.forwards || []);
// 检查有效期并显示通知
checkExpirationNotifications(data.userInfo, data.tunnelPermissions || []);
} else {
toast.error(res.msg || '获取套餐信息失败');
}
} catch (error) {
console.error('获取套餐信息失败:', error);
toast.error('获取套餐信息失败');
} finally {
setLoading(false);
}
};
const formatFlow = (value: number, unit: string = 'bytes'): string => {
// 99999 表示无限制
if (value === 99999) {
return '无限制';
}
if (unit === 'gb') {
return value + ' GB';
} else {
if (value === 0) return '0 B';
if (value < 1024) return value + ' B';
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB';
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
};
const formatNumber = (value: number): string => {
// 99999 表示无限制
if (value === 99999) {
return '无限制';
}
return value.toString();
};
const getExpStatus = (expTime?: string) => {
if (!expTime) return {
color: 'text-green-600 dark:text-green-400',
bg: 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20',
text: '永久'
};
const now = new Date();
const expDate = new Date(expTime);
if (isNaN(expDate.getTime())) {
return {
color: 'text-gray-600 dark:text-gray-400',
bg: 'bg-gray-50 dark:bg-black/10 border-gray-200 dark:border-gray-500/20',
text: '无效'
};
}
if (expDate < now) {
return {
color: 'text-red-600 dark:text-red-400',
bg: 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20',
text: '已过期'
};
}
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) {
return {
color: 'text-red-600 dark:text-red-400',
bg: 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20',
text: `${diffDays}天后过期`
};
} else if (diffDays <= 30) {
return {
color: 'text-orange-600 dark:text-orange-400',
bg: 'bg-orange-50 dark:bg-orange-500/10 border-orange-200 dark:border-orange-500/20',
text: `${diffDays}天后过期`
};
} else {
return {
color: 'text-green-600 dark:text-green-400',
bg: 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20',
text: `${diffDays}天后过期`
};
}
};
const calculateUserTotalUsedFlow = (): number => {
return (userInfo.inFlow || 0) + (userInfo.outFlow || 0);
};
const calculateUsagePercentage = (type: 'flow' | 'forwards'): number => {
if (type === 'flow') {
const totalUsed = calculateUserTotalUsedFlow();
const totalLimit = (userInfo.flow || 0) * 1024 * 1024 * 1024;
// 无限制时返回0%
if (userInfo.flow === 99999) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
} else if (type === 'forwards') {
const totalUsed = forwardList.length;
const totalLimit = userInfo.num || 0;
// 无限制时返回0%
if (userInfo.num === 99999) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
}
return 0;
};
const getUsageColor = (percentage: number) => {
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600';
if (percentage >= 70) return 'bg-orange-500 dark:bg-orange-600';
return 'bg-blue-500 dark:bg-blue-600';
};
const renderProgressBar = (percentage: number, size: 'sm' | 'md' = 'md', isUnlimited: boolean = false) => {
const height = size === 'sm' ? 'h-1.5' : 'h-2';
if (isUnlimited) {
return (
<div className="w-full">
<div className={`w-full bg-gradient-to-r from-blue-200 to-purple-200 dark:from-blue-500/30 dark:to-purple-500/30 rounded-full ${height}`}>
<div className={`${height} bg-gradient-to-r from-blue-500 to-purple-500 rounded-full w-full opacity-60`}></div>
</div>
</div>
);
}
return (
<div className="w-full">
<div className={`w-full bg-gray-200 dark:bg-gray-800 rounded-full ${height}`}>
<div
className={`${height} rounded-full transition-all duration-300 ${getUsageColor(percentage)}`}
style={{ width: `${Math.min(percentage, 100)}%` }}
></div>
</div>
</div>
);
};
const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
if (!tunnel) return 0;
const inFlow = tunnel.inFlow || 0;
const outFlow = tunnel.outFlow || 0;
if (tunnel.tunnelFlow === 1) {
return outFlow;
} else {
return inFlow + outFlow;
}
};
const calculateTunnelFlowPercentage = (tunnel: UserTunnel): number => {
const totalUsed = calculateTunnelUsedFlow(tunnel);
const totalLimit = (tunnel.flow || 0) * 1024 * 1024 * 1024;
// 无限制时返回0%
if (tunnel.flow === 99999) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
};
const getTunnelUsedForwards = (tunnelId: number): number => {
return forwardList.filter(forward => forward.tunnelId === tunnelId).length;
};
const calculateTunnelForwardPercentage = (tunnel: UserTunnel): number => {
const totalUsed = getTunnelUsedForwards(tunnel.tunnelId);
const totalLimit = tunnel.num || 0;
// 无限制时返回0%
if (tunnel.num === 99999) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
};
const formatResetTime = (resetDay?: number): string => {
if (!resetDay) return '';
const now = new Date();
const currentDay = now.getDate();
let daysUntilReset;
if (resetDay > currentDay) {
daysUntilReset = resetDay - currentDay;
} else if (resetDay < currentDay) {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, resetDay);
const diffTime = nextMonth.getTime() - now.getTime();
daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
} else {
daysUntilReset = 0;
}
if (daysUntilReset === 0) {
return '今日重置';
} else if (daysUntilReset === 1) {
return '明日重置';
} else {
return `${daysUntilReset}天后重置`;
}
};
const groupedForwards = () => {
const groups: { [key: string]: { tunnelName: string; forwards: Forward[] } } = {};
forwardList.forEach(forward => {
const tunnelName = forward.tunnelName || '未知隧道';
if (!groups[tunnelName]) {
groups[tunnelName] = {
tunnelName,
forwards: []
};
}
groups[tunnelName].forwards.push(forward);
});
return Object.values(groups);
};
const formatInAddress = (ipString: string, port: number): string => {
if (!ipString || !port) return '';
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length === 0) return '';
if (ips.length === 1) {
const ip = ips[0];
if (ip.includes(':') && !ip.startsWith('[')) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
}
const firstIp = ips[0];
let formattedFirstIp;
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
formattedFirstIp = `[${firstIp}]`;
} else {
formattedFirstIp = firstIp;
}
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
};
const formatRemoteAddress = (remoteAddr: string): string => {
if (!remoteAddr) return '';
const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length === 0) return '';
if (addresses.length === 1) {
return addresses[0];
}
return `${addresses[0]} (+${addresses.length - 1})`;
};
const hasMultipleIps = (ipString: string): boolean => {
if (!ipString) return false;
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
return ips.length > 1;
};
const hasMultipleRemoteAddresses = (remoteAddr: string): boolean => {
if (!remoteAddr) return false;
const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
return addresses.length > 1;
};
const showAddressModal = (ipString: string, port: number, title: string) => {
if (!ipString || !port) return;
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length <= 1) {
copyToClipboard(formatInAddress(ipString, port), title);
return;
}
const formattedList = 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
};
});
setAddressList(formattedList);
setAddressModalTitle(`${title} (${ips.length}个)`);
setAddressModalOpen(true);
};
const showRemoteAddressModal = (remoteAddr: string, title: string) => {
if (!remoteAddr) return;
const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length <= 1) {
copyToClipboard(remoteAddr, title);
return;
}
const formattedList = addresses.map((address, index) => {
return {
id: index,
ip: address,
address: address,
copying: false
};
});
setAddressList(formattedList);
setAddressModalTitle(`${title} (${addresses.length}个)`);
setAddressModalOpen(true);
};
const copyToClipboard = async (text: string, label: string = '内容') => {
try {
await navigator.clipboard.writeText(text);
toast.success(`已复制`);
} catch (error) {
toast.error('复制失败');
}
};
const copyAddress = async (addressItem: AddressItem) => {
try {
setAddressList(prev => prev.map(item =>
item.id === addressItem.id ? { ...item, copying: true } : item
));
await copyToClipboard(addressItem.address, '地址');
} catch (error) {
toast.error('复制失败');
} finally {
setAddressList(prev => prev.map(item =>
item.id === addressItem.id ? { ...item, copying: false } : item
));
}
};
const copyAllAddresses = async () => {
if (addressList.length === 0) return;
const allAddresses = addressList.map(item => item.address).join('\n');
await copyToClipboard(allAddresses, '所有地址');
};
const calculateForwardBillingFlow = (forward: Forward): number => {
if (!forward) return 0;
const tunnel = userTunnels.find(t => t.tunnelId === forward.tunnelId);
if (!tunnel) {
return (forward.inFlow || 0) + (forward.outFlow || 0);
}
const inFlow = forward.inFlow || 0;
const outFlow = forward.outFlow || 0;
if (tunnel.tunnelFlow === 1) {
return outFlow;
} else {
return inFlow + outFlow;
}
};
if (loading) {
return (
<AdminLayout>
<div className="px-3 lg:px-6 flex-grow pt-2 lg:pt-4">
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full"></div>
<span className="text-default-600">正在加载数据...</span>
</div>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="px-3 lg:px-6 py-2 lg:py-4">
{/* 响应式统计卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">总流量</p>
<div className="p-1.5 lg:p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">{formatFlow(userInfo.flow, 'gb')}</p>
</div>
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">已用流量</p>
<div className="p-1.5 lg:p-2 bg-green-100 dark:bg-green-500/20 rounded-lg flex-shrink-0">
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd" />
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">{formatFlow(calculateUserTotalUsedFlow())}</p>
<div className="mt-1">
{renderProgressBar(calculateUsagePercentage('flow'), 'sm', userInfo.flow === 99999)}
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-default-500 truncate">
{userInfo.flow === 99999 ? '无限制' : `${calculateUsagePercentage('flow').toFixed(1)}%`}
</p>
{userInfo.flowResetTime && (
<div className="text-xs text-default-500 flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
<span className="truncate">{formatResetTime(userInfo.flowResetTime)}</span>
</div>
)}
</div>
</div>
</div>
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">转发配额</p>
<div className="p-1.5 lg:p-2 bg-purple-100 dark:bg-purple-500/20 rounded-lg flex-shrink-0">
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 dark:text-purple-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">{formatNumber(userInfo.num || 0)}</p>
</div>
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">已用转发</p>
<div className="p-1.5 lg:p-2 bg-orange-100 dark:bg-orange-500/20 rounded-lg flex-shrink-0">
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-orange-600 dark:text-orange-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">{forwardList.length}</p>
<div className="mt-1">
{renderProgressBar(calculateUsagePercentage('forwards'), 'sm', userInfo.num === 99999)}
<p className="text-xs text-default-500 mt-1 truncate">
{userInfo.num === 99999 ? '无限制' : `${calculateUsagePercentage('forwards').toFixed(1)}%`}
</p>
</div>
</div>
</CardBody>
</Card>
</div>
{/* 隧道权限 */}
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
</svg>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">隧道权限</h2>
<span className="px-2 py-1 bg-default-100 dark:bg-default-50 text-default-600 rounded-full text-xs">
{userTunnels.length}
</span>
</div>
</CardHeader>
<CardBody className="pt-0">
{userTunnels.length === 0 ? (
<div className="text-center py-12">
<svg className="w-12 h-12 text-default-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p className="text-default-500">暂无隧道权限</p>
</div>
) : (
<div className="space-y-3">
{userTunnels.map((tunnel) => {
const tunnelExpStatus = getExpStatus(tunnel.expTime);
return (
<div key={tunnel.id} className="border border-gray-200 dark:border-default-100 rounded-lg p-3 lg:p-4 hover:shadow-md transition-shadow">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
<div>
<h3 className="font-semibold text-foreground">{tunnel.tunnelName}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1">
<span className={`px-2 py-1 rounded-md text-xs font-medium ${tunnel.tunnelFlow === 1 ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300' : 'bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300'}`}>
{tunnel.tunnelFlow === 1 ? '单向计费' : '双向计费'}
</span>
<span className={`px-2 py-1 rounded-md text-xs font-medium border ${tunnelExpStatus.bg} ${tunnelExpStatus.color}`}>
{tunnelExpStatus.text}
</span>
{tunnel.flowResetTime && (
<span className="text-xs text-default-500">
{formatResetTime(tunnel.flowResetTime)}
</span>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4">
<div>
<p className="text-sm text-default-600 mb-1">流量配额</p>
<p className="font-semibold text-foreground">{formatFlow(tunnel.flow, 'gb')}</p>
</div>
<div>
<p className="text-sm text-default-600 mb-1">已用流量</p>
<p className="font-semibold text-foreground">{formatFlow(calculateTunnelUsedFlow(tunnel))}</p>
<div className="mt-1">
{renderProgressBar(calculateTunnelFlowPercentage(tunnel), 'sm', tunnel.flow === 99999)}
</div>
</div>
<div>
<p className="text-sm text-default-600 mb-1">转发配额</p>
<p className="font-semibold text-foreground">{formatNumber(tunnel.num)}</p>
</div>
<div>
<p className="text-sm text-default-600 mb-1">已用转发</p>
<p className="font-semibold text-foreground">{getTunnelUsedForwards(tunnel.tunnelId)}</p>
<div className="mt-1">
{renderProgressBar(calculateTunnelForwardPercentage(tunnel), 'sm', tunnel.num === 99999)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</CardBody>
</Card>
{/* 转发配置 */}
<Card className="border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">转发配置</h2>
<span className="px-2 py-1 bg-default-100 dark:bg-default-50 text-default-600 rounded-full text-xs">
{forwardList.length}
</span>
</div>
</CardHeader>
<CardBody className="pt-0">
{groupedForwards().length === 0 ? (
<div className="text-center py-12">
<svg className="w-12 h-12 text-default-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
<p className="text-default-500">暂无转发配置</p>
</div>
) : (
<div className="space-y-4">
{groupedForwards().map((group) => (
<div key={group.tunnelName} className="border border-gray-200 dark:border-default-100 rounded-lg p-3 lg:p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-foreground">{group.tunnelName}</h3>
<span className="px-2 py-1 bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 rounded-md text-sm">
{group.forwards.length} 个转发
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{group.forwards.map((forward) => (
<div key={forward.id} className="bg-white dark:bg-default-100/50 border border-gray-200 dark:border-default-200 rounded-lg p-3 hover:shadow-md transition-shadow">
<div className="space-y-3">
<div>
<h4 className="font-medium text-foreground text-sm mb-2 truncate">{forward.name}</h4>
<div className="space-y-1">
<code
className={`block px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 rounded font-mono text-xs truncate ${hasMultipleIps(forward.inIp) ? 'cursor-pointer hover:bg-green-200 dark:hover:bg-green-500/30' : ''}`}
onClick={() => hasMultipleIps(forward.inIp) && showAddressModal(forward.inIp, forward.inPort, '入口地址')}
title={formatInAddress(forward.inIp, forward.inPort)}
>
{formatInAddress(forward.inIp, forward.inPort)}
</code>
<div className="text-center text-default-400 text-xs">↓</div>
<code
className={`block px-2 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded font-mono text-xs truncate ${hasMultipleRemoteAddresses(forward.remoteAddr) ? 'cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-500/30' : ''}`}
onClick={() => hasMultipleRemoteAddresses(forward.remoteAddr) && showRemoteAddressModal(forward.remoteAddr, '出口地址')}
title={formatRemoteAddress(forward.remoteAddr)}
>
{formatRemoteAddress(forward.remoteAddr)}
</code>
</div>
</div>
<div className="pt-2 border-t border-gray-200 dark:border-default-200">
<div className="grid grid-cols-3 gap-1 text-xs">
<div className="text-center">
<div className="text-default-500 mb-1">入站</div>
<div className="font-medium text-green-600 dark:text-green-400 truncate">{formatFlow(forward.inFlow || 0)}</div>
</div>
<div className="text-center">
<div className="text-default-500 mb-1">出站</div>
<div className="font-medium text-orange-600 dark:text-orange-400 truncate">{formatFlow(forward.outFlow || 0)}</div>
</div>
<div className="text-center">
<div className="text-default-500 mb-1">计费</div>
<div className="font-medium text-primary truncate">{formatFlow(calculateForwardBillingFlow(forward))}</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</CardBody>
</Card>
{/* 地址列表弹窗 */}
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg">
<ModalContent>
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
<ModalBody className="pb-6">
<div className="mb-4 text-right">
<Button size="sm" onClick={copyAllAddresses}>
复制全部
</Button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
{addressList.map((item) => (
<div key={item.id} className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg">
<code className="text-sm flex-1 mr-3 text-foreground">{item.address}</code>
<Button
size="sm"
variant="light"
isLoading={item.copying}
onClick={() => copyAddress(item)}
>
复制
</Button>
</div>
))}
</div>
</ModalBody>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
}
+863
View File
@@ -0,0 +1,863 @@
import { useState, useEffect } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner";
import { Switch } from "@heroui/switch";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
import {
createForward,
getForwardList,
updateForward,
deleteForward,
forceDeleteForward,
userTunnel,
pauseForwardService,
resumeForwardService
} from "@/api";
interface Forward {
id: number;
name: string;
tunnelId: number;
tunnelName: string;
inIp: string;
inPort: number;
remoteAddr: string;
strategy: string;
status: number;
inFlow: number;
outFlow: number;
serviceRunning: boolean;
createdTime: string;
userName?: string;
}
interface Tunnel {
id: number;
name: string;
inNodePortSta?: number;
inNodePortEnd?: number;
}
interface ForwardForm {
id?: number;
name: string;
tunnelId: number | null;
inPort: number | null;
remoteAddr: string;
strategy: string;
}
interface AddressItem {
id: number;
address: string;
copying: boolean;
}
export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [addressModalOpen, setAddressModalOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [forwardToDelete, setForwardToDelete] = useState<Forward | null>(null);
const [addressModalTitle, setAddressModalTitle] = useState('');
const [addressList, setAddressList] = useState<AddressItem[]>([]);
// 表单状态
const [form, setForm] = useState<ForwardForm>({
name: '',
tunnelId: null,
inPort: null,
remoteAddr: '',
strategy: 'fifo'
});
// 表单验证错误
const [errors, setErrors] = useState<{[key: string]: string}>({});
const [selectedTunnel, setSelectedTunnel] = useState<Tunnel | null>(null);
useEffect(() => {
loadData();
}, []);
// 加载所有数据
const loadData = async () => {
setLoading(true);
try {
const [forwardsRes, tunnelsRes] = await Promise.all([
getForwardList(),
userTunnel()
]);
if (forwardsRes.code === 0) {
setForwards(forwardsRes.data?.map((forward: any) => ({
...forward,
serviceRunning: forward.status === 1
})) || []);
} else {
toast.error(forwardsRes.msg || '获取转发列表失败');
}
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
} else {
console.warn('获取隧道列表失败:', tunnelsRes.msg);
}
} catch (error) {
console.error('加载数据失败:', error);
toast.error('加载数据失败');
} finally {
setLoading(false);
}
};
// 表单验证
const validateForm = (): boolean => {
const newErrors: {[key: string]: string} = {};
if (!form.name.trim()) {
newErrors.name = '请输入转发名称';
} else if (form.name.length < 2 || form.name.length > 50) {
newErrors.name = '转发名称长度应在2-50个字符之间';
}
if (!form.tunnelId) {
newErrors.tunnelId = '请选择关联隧道';
}
if (!form.remoteAddr.trim()) {
newErrors.remoteAddr = '请输入远程地址';
} else {
// 验证地址格式
const addresses = form.remoteAddr.split('\n').map(addr => addr.trim()).filter(addr => addr);
const ipv4Pattern = /^(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]?):\d+$/;
const ipv6FullPattern = /^\[((([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}))|:)))\]:\d+$/;
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*:\d+$/;
for (let i = 0; i < addresses.length; i++) {
const addr = addresses[i];
if (!ipv4Pattern.test(addr) && !ipv6FullPattern.test(addr) && !domainPattern.test(addr)) {
newErrors.remoteAddr = `第${i + 1}行地址格式错误`;
break;
}
}
}
if (form.inPort !== null && (form.inPort < 1 || form.inPort > 65535)) {
newErrors.inPort = '端口号必须在1-65535之间';
}
if (selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd && form.inPort) {
if (form.inPort < selectedTunnel.inNodePortSta || form.inPort > selectedTunnel.inNodePortEnd) {
newErrors.inPort = `端口号必须在${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}范围内`;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 新增转发
const handleAdd = () => {
setIsEdit(false);
setForm({
name: '',
tunnelId: null,
inPort: null,
remoteAddr: '',
strategy: 'fifo'
});
setSelectedTunnel(null);
setErrors({});
setModalOpen(true);
};
// 编辑转发
const handleEdit = (forward: Forward) => {
setIsEdit(true);
setForm({
id: forward.id,
name: forward.name,
tunnelId: forward.tunnelId,
inPort: forward.inPort,
remoteAddr: forward.remoteAddr.split(',').join('\n'),
strategy: forward.strategy || 'fifo'
});
const tunnel = tunnels.find(t => t.id === forward.tunnelId);
setSelectedTunnel(tunnel || null);
setErrors({});
setModalOpen(true);
};
// 显示删除确认
const handleDelete = (forward: Forward) => {
setForwardToDelete(forward);
setDeleteModalOpen(true);
};
// 确认删除转发
const confirmDelete = async () => {
if (!forwardToDelete) return;
setDeleteLoading(true);
try {
const res = await deleteForward(forwardToDelete.id);
if (res.code === 0) {
toast.success('删除成功');
setDeleteModalOpen(false);
loadData();
} else {
// 删除失败,询问是否强制删除
const confirmed = window.confirm(`常规删除失败:${res.msg || '删除失败'}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务。`);
if (confirmed) {
const forceRes = await forceDeleteForward(forwardToDelete.id);
if (forceRes.code === 0) {
toast.success('强制删除成功');
setDeleteModalOpen(false);
loadData();
} else {
toast.error(forceRes.msg || '强制删除失败');
}
}
}
} catch (error) {
console.error('删除失败:', error);
toast.error('删除失败');
} finally {
setDeleteLoading(false);
}
};
// 处理隧道选择变化
const handleTunnelChange = (tunnelId: string) => {
const tunnel = tunnels.find(t => t.id === parseInt(tunnelId));
setSelectedTunnel(tunnel || null);
setForm(prev => ({ ...prev, tunnelId: parseInt(tunnelId) }));
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
setSubmitLoading(true);
try {
const processedRemoteAddr = form.remoteAddr
.split('\n')
.map(addr => addr.trim())
.filter(addr => addr)
.join(',');
const addressCount = processedRemoteAddr.split(',').length;
let res;
if (isEdit) {
res = await updateForward({
...form,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : 'fifo'
});
} else {
const { id, ...createData } = form;
res = await createForward({
...createData,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : 'fifo'
});
}
if (res.code === 0) {
toast.success(isEdit ? '修改成功' : '创建成功');
setModalOpen(false);
loadData();
} else {
toast.error(res.msg || '操作失败');
}
} catch (error) {
console.error('提交失败:', error);
toast.error('操作失败');
} finally {
setSubmitLoading(false);
}
};
// 处理服务开关
const handleServiceToggle = async (forward: Forward) => {
if (forward.status !== 1 && forward.status !== 0) {
toast.error('转发状态异常,无法操作');
return;
}
const targetState = !forward.serviceRunning;
try {
// 乐观更新UI
setForwards(prev => prev.map(f =>
f.id === forward.id
? { ...f, serviceRunning: targetState }
: f
));
let res;
if (targetState) {
res = await resumeForwardService(forward.id);
} else {
res = await pauseForwardService(forward.id);
}
if (res.code === 0) {
toast.success(targetState ? '服务已启动' : '服务已暂停');
// 更新转发状态
setForwards(prev => prev.map(f =>
f.id === forward.id
? { ...f, status: targetState ? 1 : 0 }
: f
));
} else {
// 操作失败,恢复UI状态
setForwards(prev => prev.map(f =>
f.id === forward.id
? { ...f, serviceRunning: !targetState }
: f
));
toast.error(res.msg || '操作失败');
}
} catch (error) {
// 操作失败,恢复UI状态
setForwards(prev => prev.map(f =>
f.id === forward.id
? { ...f, serviceRunning: !targetState }
: f
));
console.error('服务开关操作失败:', error);
toast.error('网络错误,操作失败');
}
};
// 格式化流量
const formatFlow = (value: number): string => {
if (value === 0) return '0 B';
if (value < 1024) return value + ' B';
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB';
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};
// 格式化入口地址
const formatInAddress = (ipString: string, port: number): string => {
if (!ipString || !port) return '';
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length === 0) return '';
if (ips.length === 1) {
const ip = ips[0];
if (ip.includes(':') && !ip.startsWith('[')) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
}
const firstIp = ips[0];
let formattedFirstIp;
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
formattedFirstIp = `[${firstIp}]`;
} else {
formattedFirstIp = firstIp;
}
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
};
// 格式化远程地址
const formatRemoteAddress = (addressString: string): string => {
if (!addressString) return '';
const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length === 0) return '';
if (addresses.length === 1) return addresses[0];
return `${addresses[0]} (+${addresses.length - 1})`;
};
// 检查是否有多个地址
const hasMultipleAddresses = (addressString: string): boolean => {
if (!addressString) return false;
const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
return addresses.length > 1;
};
// 显示地址列表弹窗
const showAddressModal = (addressString: string, port: number | null, title: string) => {
if (!addressString) return;
let addresses: string[];
if (port !== null) {
// 入口地址处理
const ips = addressString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length <= 1) {
copyToClipboard(formatInAddress(addressString, port), title);
return;
}
addresses = ips.map(ip => {
if (ip.includes(':') && !ip.startsWith('[')) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
});
} else {
// 远程地址处理
addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length <= 1) {
copyToClipboard(addressString, title);
return;
}
}
setAddressList(addresses.map((address, index) => ({
id: index,
address,
copying: false
})));
setAddressModalTitle(`${title} (${addresses.length}个)`);
setAddressModalOpen(true);
};
// 复制到剪贴板
const copyToClipboard = async (text: string, label: string = '内容') => {
try {
await navigator.clipboard.writeText(text);
toast.success(`已复制${label}`);
} catch (error) {
toast.error('复制失败');
}
};
// 复制地址
const copyAddress = async (addressItem: AddressItem) => {
try {
setAddressList(prev => prev.map(item =>
item.id === addressItem.id ? { ...item, copying: true } : item
));
await copyToClipboard(addressItem.address, '地址');
} catch (error) {
toast.error('复制失败');
} finally {
setAddressList(prev => prev.map(item =>
item.id === addressItem.id ? { ...item, copying: false } : item
));
}
};
// 复制所有地址
const copyAllAddresses = async () => {
if (addressList.length === 0) return;
const allAddresses = addressList.map(item => item.address).join('\n');
await copyToClipboard(allAddresses, '所有地址');
};
// 获取状态显示
const getStatusDisplay = (status: number) => {
switch (status) {
case 1:
return { color: 'success', text: '正常' };
case 0:
return { color: 'warning', text: '暂停' };
case -1:
return { color: 'danger', text: '异常' };
default:
return { color: 'default', text: '未知' };
}
};
// 获取策略显示
const getStrategyDisplay = (strategy: string) => {
switch (strategy) {
case 'fifo':
return { color: 'primary', text: '主备' };
case 'round':
return { color: 'success', text: '轮询' };
case 'rand':
return { color: 'warning', text: '随机' };
default:
return { color: 'default', text: '未知' };
}
};
// 获取地址数量
const getAddressCount = (addressString: string): number => {
if (!addressString) return 0;
const addresses = addressString.split('\n').map(addr => addr.trim()).filter(addr => addr);
return addresses.length;
};
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground">转发管理</h1>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增转发
</Button>
</div>
{/* 转发卡片网格 */}
{forwards.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{forwards.map((forward) => {
const statusDisplay = getStatusDisplay(forward.status);
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<Card key={forward.id} className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardHeader className="pb-3">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate">{forward.name}</h3>
<p className="text-sm text-default-500 truncate mt-1">{forward.tunnelName}</p>
</div>
<div className="flex items-center gap-2 ml-2">
<Switch
size="sm"
isSelected={forward.serviceRunning}
onValueChange={() => handleServiceToggle(forward)}
isDisabled={forward.status !== 1 && forward.status !== 0}
/>
<Chip
color={statusDisplay.color as any}
variant="flat"
size="sm"
>
{statusDisplay.text}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="space-y-3">
{/* 地址信息 */}
<div className="space-y-2">
<div
className={`cursor-pointer p-2 bg-green-50 dark:bg-green-500/10 rounded border border-green-200 dark:border-green-500/20 ${
hasMultipleAddresses(forward.inIp) ? 'hover:bg-green-100 dark:hover:bg-green-500/20' : ''
}`}
onClick={() => showAddressModal(forward.inIp, forward.inPort, '入口地址')}
title={formatInAddress(forward.inIp, forward.inPort)}
>
<div className="text-xs text-green-600 dark:text-green-400 mb-1">入口地址</div>
<code className="text-xs font-mono text-green-700 dark:text-green-300 truncate block">
{formatInAddress(forward.inIp, forward.inPort)}
</code>
</div>
<div className="text-center text-default-400 text-xs">↓</div>
<div
className={`cursor-pointer p-2 bg-blue-50 dark:bg-blue-500/10 rounded border border-blue-200 dark:border-blue-500/20 ${
hasMultipleAddresses(forward.remoteAddr) ? 'hover:bg-blue-100 dark:hover:bg-blue-500/20' : ''
}`}
onClick={() => showAddressModal(forward.remoteAddr, null, '目标地址')}
title={formatRemoteAddress(forward.remoteAddr)}
>
<div className="text-xs text-blue-600 dark:text-blue-400 mb-1">目标地址</div>
<code className="text-xs font-mono text-blue-700 dark:text-blue-300 truncate block">
{formatRemoteAddress(forward.remoteAddr)}
</code>
</div>
</div>
{/* 策略和流量 */}
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-default-200">
<div className="text-center flex-1">
<div className="text-xs text-default-500 mb-1">策略</div>
<Chip color={strategyDisplay.color as any} variant="flat" size="sm">
{strategyDisplay.text}
</Chip>
</div>
<div className="text-center flex-1">
<div className="text-xs text-default-500 mb-1">总流量</div>
<div className="text-xs font-medium text-primary">
{formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))}
</div>
</div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(forward)}
className="flex-1"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(forward)}
className="flex-1"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
</svg>
}
>
删除
</Button>
</div>
</CardBody>
</Card>
);
})}
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">暂无转发配置</h3>
<p className="text-default-500 text-sm mt-1">还没有创建任何转发配置,点击上方按钮开始创建</p>
</div>
</div>
</CardBody>
</Card>
)}
{/* 新增/编辑模态框 */}
<Modal
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="lg"
scrollBehavior="inside"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">
{isEdit ? '编辑转发' : '新增转发'}
</h2>
<p className="text-small text-default-500">
{isEdit ? '修改现有转发配置的信息' : '创建新的转发配置'}
</p>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Input
label="转发名称"
placeholder="请输入转发名称"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
isInvalid={!!errors.name}
errorMessage={errors.name}
variant="bordered"
/>
<Select
label="选择隧道"
placeholder="请选择关联的隧道"
selectedKeys={form.tunnelId ? [form.tunnelId.toString()] : []}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
handleTunnelChange(selectedKey);
}
}}
isInvalid={!!errors.tunnelId}
errorMessage={errors.tunnelId}
variant="bordered"
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id} value={tunnel.id}>
{tunnel.name}
</SelectItem>
))}
</Select>
<Input
label="入口端口"
placeholder="留空自动分配"
type="number"
value={form.inPort?.toString() || ''}
onChange={(e) => setForm(prev => ({
...prev,
inPort: e.target.value ? parseInt(e.target.value) : null
}))}
isInvalid={!!errors.inPort}
errorMessage={errors.inPort}
variant="bordered"
description={
selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd
? `允许范围: ${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}`
: '留空将自动分配可用端口'
}
/>
<Input
label="远程地址"
placeholder="请输入远程地址,多个地址用换行分隔"
value={form.remoteAddr}
onChange={(e) => setForm(prev => ({ ...prev, remoteAddr: e.target.value }))}
isInvalid={!!errors.remoteAddr}
errorMessage={errors.remoteAddr}
variant="bordered"
description="格式: IP:端口 或 域名:端口,支持多个地址(每行一个)"
/>
{getAddressCount(form.remoteAddr) > 1 && (
<Select
label="负载策略"
placeholder="请选择负载均衡策略"
selectedKeys={[form.strategy]}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
setForm(prev => ({ ...prev, strategy: selectedKey }));
}}
variant="bordered"
description="多个目标地址的负载均衡策略"
>
<SelectItem key="fifo" value="fifo">主备模式 - 自上而下</SelectItem>
<SelectItem key="round" value="round">轮询模式 - 依次轮换</SelectItem>
<SelectItem key="rand" value="rand">随机模式 - 随机选择</SelectItem>
</Select>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={submitLoading}
>
{isEdit ? '保存修改' : '创建转发'}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 删除确认模态框 */}
<Modal
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-danger">确认删除</h2>
</ModalHeader>
<ModalBody>
<p className="text-default-600">
确定要删除转发 <span className="font-semibold text-foreground">"{forwardToDelete?.name}"</span> 吗?
</p>
<p className="text-small text-default-500 mt-2">
此操作无法撤销,删除后该转发将永久消失。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="danger"
onPress={confirmDelete}
isLoading={deleteLoading}
>
确认删除
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 地址列表弹窗 */}
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg">
<ModalContent>
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
<ModalBody className="pb-6">
<div className="mb-4 text-right">
<Button size="sm" onClick={copyAllAddresses}>
复制全部
</Button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
{addressList.map((item) => (
<div key={item.id} className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg">
<code className="text-sm flex-1 mr-3 text-foreground">{item.address}</code>
<Button
size="sm"
variant="light"
isLoading={item.copying}
onClick={() => copyAddress(item)}
>
复制
</Button>
</div>
))}
</div>
</ModalBody>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from 'react-hot-toast';
import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default";
import { login, LoginData } from "@/api";
interface LoginForm {
username: string;
password: string;
}
export default function IndexPage() {
const [form, setForm] = useState<LoginForm>({
username: "",
password: ""
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<LoginForm>>({});
const navigate = useNavigate();
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<LoginForm> = {};
if (!form.username.trim()) {
newErrors.username = '请输入用户名';
}
if (!form.password.trim()) {
newErrors.password = '请输入密码';
} else if (form.password.length < 6) {
newErrors.password = '密码长度至少6位';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理输入变化
const handleInputChange = (field: keyof LoginForm, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
// 清除该字段的错误
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
const handleLogin = async () => {
if (!validateForm()) return;
setLoading(true);
try {
const loginData: LoginData = {
username: form.username.trim(),
password: form.password
};
const response = await login(loginData);
if (response.code !== 0) {
toast.error(response.msg || "登陆失败");
return;
}
// 检查是否需要强制修改密码
if (response.data.requirePasswordChange) {
localStorage.setItem('token', response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name);
// 设置admin字段 - role_id为0的是管理员
localStorage.setItem("admin", (response.data.role_id === 0).toString());
toast.success('检测到默认密码,即将跳转到修改密码页面');
// 直接跳转到修改密码页面
navigate("/change-password");
return;
}
// 保存登陆信息
localStorage.setItem('token', response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name);
// 设置admin字段 - role_id为0的是管理员
localStorage.setItem("admin", (response.data.role_id === 0).toString());
// 登陆成功
toast.success('登陆成功');
navigate("/dashboard");
} catch (error) {
console.error('登陆错误:', error);
toast.error("网络错误,请稍后重试");
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) {
handleLogin();
}
};
return (
<DefaultLayout>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10 min-h-[calc(100vh-200px)]">
<div className="w-full max-w-md">
<Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<h1 className={title({ size: "sm" })}>登陆</h1>
<p className="text-small text-default-500 mt-2">请输入您的账号信息</p>
</CardHeader>
<CardBody className="px-6 py-6">
<div className="flex flex-col gap-4">
<Input
label="用户名"
placeholder="请输入用户名"
value={form.username}
onChange={(e) => handleInputChange('username', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.username}
errorMessage={errors.username}
/>
<Input
label="密码"
placeholder="请输入密码"
type="password"
value={form.password}
onChange={(e) => handleInputChange('password', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.password}
errorMessage={errors.password}
/>
<Button
color="primary"
size="lg"
onClick={handleLogin}
isLoading={loading}
disabled={loading}
className="mt-2"
>
{loading ? "登陆中..." : "登陆"}
</Button>
</div>
</CardBody>
</Card>
</div>
</section>
</DefaultLayout>
);
}
+470
View File
@@ -0,0 +1,470 @@
import { useState, useEffect } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
import {
createSpeedLimit,
getSpeedLimitList,
updateSpeedLimit,
deleteSpeedLimit,
getTunnelList
} from "@/api";
interface SpeedLimitRule {
id: number;
name: string;
speed: number;
status: number;
tunnelId: number;
tunnelName: string;
createdTime: string;
updatedTime: string;
}
interface Tunnel {
id: number;
name: string;
}
interface SpeedLimitForm {
id?: number;
name: string;
speed: number;
tunnelId: number | null;
tunnelName: string;
status: number;
}
export default function LimitPage() {
const [loading, setLoading] = useState(true);
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState<SpeedLimitRule | null>(null);
// 表单状态
const [form, setForm] = useState<SpeedLimitForm>({
name: '',
speed: 100,
tunnelId: null,
tunnelName: '',
status: 1
});
// 表单验证错误
const [errors, setErrors] = useState<{[key: string]: string}>({});
useEffect(() => {
loadData();
}, []);
// 加载所有数据
const loadData = async () => {
setLoading(true);
try {
const [rulesRes, tunnelsRes] = await Promise.all([
getSpeedLimitList(),
getTunnelList()
]);
if (rulesRes.code === 0) {
setRules(rulesRes.data || []);
} else {
toast.error(rulesRes.msg || '获取限速规则失败');
}
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
} else {
console.warn('获取隧道列表失败:', tunnelsRes.msg);
}
} catch (error) {
console.error('加载数据失败:', error);
toast.error('加载数据失败');
} finally {
setLoading(false);
}
};
// 表单验证
const validateForm = (): boolean => {
const newErrors: {[key: string]: string} = {};
if (!form.name.trim()) {
newErrors.name = '请输入规则名称';
} else if (form.name.length < 2 || form.name.length > 50) {
newErrors.name = '规则名称长度应在2-50个字符之间';
}
if (!form.speed || form.speed < 1) {
newErrors.speed = '请输入有效的速度限制(≥1 Mbps)';
}
if (!form.tunnelId) {
newErrors.tunnelId = '请选择要绑定的隧道';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 新增规则
const handleAdd = () => {
setIsEdit(false);
setForm({
name: '',
speed: 100,
tunnelId: null,
tunnelName: '',
status: 1
});
setErrors({});
setModalOpen(true);
};
// 编辑规则
const handleEdit = (rule: SpeedLimitRule) => {
setIsEdit(true);
setForm({
id: rule.id,
name: rule.name,
speed: rule.speed,
tunnelId: rule.tunnelId,
tunnelName: rule.tunnelName,
status: rule.status
});
setErrors({});
setModalOpen(true);
};
// 显示删除确认
const handleDelete = (rule: SpeedLimitRule) => {
setRuleToDelete(rule);
setDeleteModalOpen(true);
};
// 确认删除规则
const confirmDelete = async () => {
if (!ruleToDelete) return;
setDeleteLoading(true);
try {
const res = await deleteSpeedLimit(ruleToDelete.id);
if (res.code === 0) {
toast.success('删除成功');
setDeleteModalOpen(false);
loadData();
} else {
toast.error(res.msg || '删除失败');
}
} catch (error) {
console.error('删除失败:', error);
toast.error('删除失败');
} finally {
setDeleteLoading(false);
}
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
setSubmitLoading(true);
try {
let res;
if (isEdit) {
res = await updateSpeedLimit(form);
} else {
const { id, ...createData } = form;
res = await createSpeedLimit(createData);
}
if (res.code === 0) {
toast.success(isEdit ? '修改成功' : '创建成功');
setModalOpen(false);
loadData();
} else {
toast.error(res.msg || '操作失败');
}
} catch (error) {
console.error('提交失败:', error);
toast.error('操作失败');
} finally {
setSubmitLoading(false);
}
};
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground">限速管理</h1>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增规则
</Button>
</div>
{/* 统一卡片网格 */}
{rules.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{rules.map((rule) => (
<Card key={rule.id} className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardHeader className="pb-3">
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">{rule.name}</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
variant="flat"
size="sm"
>
{rule.status === 1 ? '运行' : '异常'}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-small text-default-600">速度限制</span>
<Chip color="secondary" variant="flat" size="sm">
{rule.speed} Mbps
</Chip>
</div>
<div className="flex justify-between items-center">
<span className="text-small text-default-600">绑定隧道</span>
{rule.tunnelName ? (
<Chip color="primary" variant="flat" size="sm">
{rule.tunnelName}
</Chip>
) : (
<span className="text-default-400 text-small">未绑定</span>
)}
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(rule)}
className="flex-1"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(rule)}
className="flex-1"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
</svg>
}
>
删除
</Button>
</div>
</CardBody>
</Card>
))}
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">暂无限速规则</h3>
<p className="text-default-500 text-sm mt-1">还没有创建任何限速规则,点击上方按钮开始创建</p>
</div>
</div>
</CardBody>
</Card>
)}
{/* 新增/编辑模态框 */}
<Modal
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="lg"
scrollBehavior="inside"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">
{isEdit ? '编辑限速规则' : '新增限速规则'}
</h2>
<p className="text-small text-default-500">
{isEdit ? '修改现有限速规则的配置信息' : '创建新的限速规则并绑定到隧道'}
</p>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Input
label="规则名称"
placeholder="请输入限速规则名称"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
isInvalid={!!errors.name}
errorMessage={errors.name}
variant="bordered"
/>
<Input
label="速度限制"
placeholder="请输入速度限制"
type="number"
value={form.speed.toString()}
onChange={(e) => setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))}
isInvalid={!!errors.speed}
errorMessage={errors.speed}
variant="bordered"
endContent={
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">Mbps</span>
</div>
}
/>
<Select
label="绑定隧道"
placeholder="请选择要绑定的隧道"
selectedKeys={form.tunnelId ? [form.tunnelId.toString()] : []}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
const selectedTunnel = tunnels.find(tunnel => tunnel.id === parseInt(selectedKey));
setForm(prev => ({
...prev,
tunnelId: parseInt(selectedKey),
tunnelName: selectedTunnel?.name || ''
}));
} else {
setForm(prev => ({
...prev,
tunnelId: null,
tunnelName: ''
}));
}
}}
isInvalid={!!errors.tunnelId}
errorMessage={errors.tunnelId}
variant="bordered"
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id}>
{tunnel.name}
</SelectItem>
))}
</Select>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={submitLoading}
>
{isEdit ? '保存修改' : '创建规则'}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 删除确认模态框 */}
<Modal
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-danger">确认删除</h2>
</ModalHeader>
<ModalBody>
<p className="text-default-600">
确定要删除限速规则 <span className="font-semibold text-foreground">"{ruleToDelete?.name}"</span> 吗?
</p>
<p className="text-small text-default-500 mt-2">
此操作无法撤销,删除后该规则将永久消失。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="danger"
onPress={confirmDelete}
isLoading={deleteLoading}
>
确认删除
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { NavigateOptions } from "react-router-dom";
import * as React from "react";
import { HeroUIProvider } from "@heroui/system";
import { useHref, useNavigate } from "react-router-dom";
import { Toaster } from 'react-hot-toast';
import { ThemeProvider } from '@/components/theme-provider';
declare module "@react-types/shared" {
interface RouterConfig {
routerOptions: NavigateOptions;
}
}
export interface ProvidersProps {
children: React.ReactNode;
}
export function Provider({ children }: ProvidersProps) {
const navigate = useNavigate();
return (
<HeroUIProvider navigate={navigate} useHref={useHref}>
<ThemeProvider>
{children}
<Toaster
position="top-center"
toastOptions={{
duration: 3000,
className: 'dark:bg-gray-800 dark:text-white',
style: {
background: 'var(--toaster-bg, #ffffff)',
color: 'var(--toaster-color, #000000)',
border: '1px solid var(--toaster-border, #e5e7eb)',
},
success: {
style: {
background: '#10b981',
color: '#ffffff',
},
},
error: {
style: {
background: '#ef4444',
color: '#ffffff',
},
},
}}
/>
</ThemeProvider>
</HeroUIProvider>
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+5
View File
@@ -0,0 +1,5 @@
import { SVGProps } from "react";
export type IconSvgProps = SVGProps<SVGSVGElement> & {
size?: number;
};
+72
View File
@@ -0,0 +1,72 @@
import { getRoleIdFromToken, isTokenValid } from './jwt';
/**
* 权限工具类
*/
/**
* 获取当前用户的token
* @returns token
*/
export function getToken(): string | null {
return localStorage.getItem('token');
}
/**
* 获取当前用户的角色ID
* @returns 角色ID
*/
export function getCurrentUserRoleId(): number | null {
const token = getToken();
if (!token || !isTokenValid(token)) {
return null;
}
return getRoleIdFromToken(token);
}
/**
* 判断当前用户是否是管理员
* @returns 是否是管理员
*/
export function isAdmin(): boolean {
const roleId = getCurrentUserRoleId();
return roleId === 0;
}
/**
* 判断当前用户是否有指定角色
* @param targetRoleId 目标角色ID
* @returns 是否有指定角色
*/
export function hasRole(targetRoleId: number): boolean {
const roleId = getCurrentUserRoleId();
return roleId === targetRoleId;
}
/**
* 判断当前用户是否已登录且token有效
* @returns 是否已登录
*/
export function isLoggedIn(): boolean {
const token = getToken();
return token ? isTokenValid(token) : false;
}
/**
* 权限检查装饰器函数
* @param fn 要执行的函数
* @param errorMsg 权限不足时的错误提示
* @returns 包装后的函数
*/
export function requireAdmin<T extends (...args: any[]) => any>(
fn: T,
errorMsg: string = '权限不足,仅管理员可操作'
): T {
return ((...args: Parameters<T>) => {
if (!isAdmin()) {
console.warn(errorMsg);
return false;
}
return fn(...args);
}) as T;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* JWT工具类 - TypeScript版本
*/
interface JWTPayload {
sub: string;
role_id: number;
user: string;
exp: number;
iat: number;
}
/**
* 从JWT Token中获取payload
* @param token JWT Token
* @returns payload数据
*/
function getPayloadFromToken(token: string): JWTPayload | null {
try {
if (!token) return null;
const parts = token.split('.');
if (parts.length !== 3) return null;
const encodedPayload = parts[1];
const decodedPayload = atob(encodedPayload);
return JSON.parse(decodedPayload) as JWTPayload;
} catch (error) {
return null;
}
}
/**
* 从JWT Token中获取用户ID
* @param token JWT Token
* @returns 用户ID
*/
export function getUserIdFromToken(token: string): number | null {
const payload = getPayloadFromToken(token);
return payload ? parseInt(payload.sub) : null;
}
/**
* 从JWT Token中获取用户角色ID
* @param token JWT Token
* @returns 角色ID
*/
export function getRoleIdFromToken(token: string): number | null {
const payload = getPayloadFromToken(token);
return payload ? payload.role_id : null;
}
/**
* 从JWT Token中获取用户名
* @param token JWT Token
* @returns 用户名
*/
export function getUsernameFromToken(token: string): string | null {
const payload = getPayloadFromToken(token);
return payload ? payload.user : null;
}
/**
* 验证token是否过期
* @param token JWT Token
* @returns 是否有效
*/
export function isTokenValid(token: string): boolean {
const payload = getPayloadFromToken(token);
if (!payload) return false;
const now = Math.floor(Date.now() / 1000);
return payload.exp > now;
}
// JwtUtil对象,提供便捷的静态方法调用
export const JwtUtil = {
/**
* 从localStorage获取token并解析用户ID
* @returns 用户ID
*/
getUserIdFromToken(): number | null {
const token = localStorage.getItem('token');
return token ? getUserIdFromToken(token) : null;
},
/**
* 从localStorage获取token并解析角色ID
* @returns 角色ID
*/
getRoleIdFromToken(): number | null {
const token = localStorage.getItem('token');
return token ? getRoleIdFromToken(token) : null;
},
/**
* 从localStorage获取token并解析用户名
* @returns 用户名
*/
getUsernameFromToken(): string | null {
const token = localStorage.getItem('token');
return token ? getUsernameFromToken(token) : null;
},
/**
* 验证localStorage中的token是否有效
* @returns 是否有效
*/
isTokenValid(): boolean {
const token = localStorage.getItem('token');
return token ? isTokenValid(token) : false;
}
};
+24
View File
@@ -0,0 +1,24 @@
/**
* 安全退出登录函数
* 清除登录相关数据,但保留用户偏好设置(如主题)
*/
export const safeLogout = () => {
// 需要保留的用户偏好设置
const preservedSettings = {
theme: localStorage.getItem('heroui-theme'),
// 可以在这里添加其他需要保留的设置
};
// 清除所有localStorage数据
localStorage.clear();
// 恢复保留的设置
Object.entries(preservedSettings).forEach(([key, value]) => {
if (value) {
if (key === 'theme') {
localStorage.setItem('heroui-theme', value);
}
// 可以在这里添加其他设置的恢复逻辑
}
});
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+17
View File
@@ -0,0 +1,17 @@
import {heroui} from "@heroui/theme"
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
'./src/layouts/**/*.{js,ts,jsx,tsx,mdx}',
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
darkMode: "class",
plugins: [heroui()],
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"rewrites": [
{ "source": "/(.*)", "destination": "/" }
]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 3000,
host: '0.0.0.0'
},
build: {
outDir: 'dist',
sourcemap: false
}
});
+11 -6
View File
@@ -241,8 +241,11 @@
>
<template slot="prepend">端口</template>
</el-input>
<div class="form-hint" v-if="selectedTunnel">
允许范围: {{ selectedTunnel.inPortSta }}-{{ selectedTunnel.inPortEnd }},留空将自动分配可用端口
<div class="form-hint" v-if="selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd">
允许范围: {{ selectedTunnel.inNodePortSta }}-{{ selectedTunnel.inNodePortEnd }},留空将自动分配可用端口
</div>
<div class="form-hint" v-else-if="selectedTunnel">
已选择隧道: {{ selectedTunnel.name }},留空将自动分配可用端口
</div>
<div class="form-hint" v-else>
请先选择隧道以查看端口范围,留空将自动分配可用端口
@@ -430,10 +433,10 @@ export default {
return;
}
// 检查是否在隧道允许范围内
if (this.selectedTunnel) {
if (value < this.selectedTunnel.inPortSta || value > this.selectedTunnel.inPortEnd) {
callback(new Error(`端口号必须在${this.selectedTunnel.inPortSta}-${this.selectedTunnel.inPortEnd}范围内`));
// 检查是否在节点允许范围内
if (this.selectedTunnel && this.selectedTunnel.inNodePortSta && this.selectedTunnel.inNodePortEnd) {
if (value < this.selectedTunnel.inNodePortSta || value > this.selectedTunnel.inNodePortEnd) {
callback(new Error(`端口号必须在${this.selectedTunnel.inNodePortSta}-${this.selectedTunnel.inNodePortEnd}范围内`));
return;
}
}
@@ -582,6 +585,8 @@ export default {
async handleAdd() {
this.isEdit = false;
this.dialogTitle = '新增转发';
// 重置表单状态,确保selectedTunnel被清空
this.resetForm();
try {
// 只在没有隧道数据时才加载
+1 -1
View File
@@ -9,7 +9,7 @@
@click="toggleMobileMenu"
type="text">
</el-button>
<span style="line-height: 50px">管理后台<span style="margin-left: 10px; font-size: 12px">1.0.1</span></span>
<span style="line-height: 50px">管理后台<span style="margin-left: 10px; font-size: 12px">1.0.2</span></span>
</div>
<div class="header-avatar">
<el-avatar style="margin-right: 10px" size="medium" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"></el-avatar>
+1 -83
View File
@@ -208,24 +208,7 @@
</el-select>
</el-form-item>
<el-form-item label="起始端口" prop="inPortSta">
<el-input-number
v-model="tunnelForm.inPortSta"
:min="1"
:max="65535"
placeholder="起始端口"
style="width: 100%"
></el-input-number>
</el-form-item>
<el-form-item label="结束端口" prop="inPortEnd">
<el-input-number
v-model="tunnelForm.inPortEnd"
:min="1"
:max="65535"
placeholder="结束端口"
style="width: 100%"
></el-input-number>
</el-form-item>
<el-form-item label="监听地址" prop="tcpListenAddr">
<el-input
@@ -297,24 +280,6 @@
</el-select>
</el-form-item>
<el-form-item label="起始端口" prop="outIpSta">
<el-input-number
v-model="tunnelForm.outIpSta"
:min="1"
:max="65535"
placeholder="起始端口"
style="width: 100%"
></el-input-number>
</el-form-item>
<el-form-item label="结束端口" prop="outIpEnd">
<el-input-number
v-model="tunnelForm.outIpEnd"
:min="1"
:max="65535"
placeholder="结束端口"
style="width: 100%"
></el-input-number>
</el-form-item>
</template>
<el-alert
@@ -541,11 +506,7 @@ export default {
id: null,
name: '',
inNodeId: null,
inPortSta: 1,
inPortEnd: 65535,
outNodeId: null,
outIpSta: 1,
outIpEnd: 65535,
type: 1,
flow: 1, // 默认单向计算
status: 1, // 默认启用
@@ -563,12 +524,6 @@ export default {
inNodeId: [
{ required: true, message: '请选择入口节点', trigger: 'change' }
],
inPortSta: [
{ required: true, message: '请输入起始端口', trigger: 'blur' }
],
inPortEnd: [
{ required: true, message: '请输入结束端口', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择隧道类型', trigger: 'change' }
],
@@ -583,12 +538,6 @@ export default {
outNodeId: [
{ required: false, message: '请选择出口节点', trigger: 'change' }
],
outIpSta: [
{ required: false, message: '请输入起始端口', trigger: 'blur' }
],
outIpEnd: [
{ required: false, message: '请输入结束端口', trigger: 'blur' }
],
protocol: [
{ required: false, message: '请选择协议类型', trigger: 'change' }
],
@@ -683,10 +632,6 @@ export default {
name: tunnel.name,
flow: tunnel.flow,
trafficRatio: tunnel.trafficRatio || 1.0,
inPortSta: tunnel.inPortSta,
inPortEnd: tunnel.inPortEnd,
outIpSta: tunnel.outIpSta,
outIpEnd: tunnel.outIpEnd,
tcpListenAddr: tunnel.tcpListenAddr || '0.0.0.0',
udpListenAddr: tunnel.udpListenAddr || '0.0.0.0',
// 以下字段不允许修改,但需要保留原值用于显示和提交
@@ -727,20 +672,14 @@ export default {
if (value === 2) {
// 隧道转发时,出口字段变为必填
this.rules.outNodeId[0].required = true;
this.rules.outIpSta[0].required = true;
this.rules.outIpEnd[0].required = true;
this.rules.protocol[0].required = true;
} else {
// 端口转发时,出口字段不必填
this.rules.outNodeId[0].required = false;
this.rules.outIpSta[0].required = false;
this.rules.outIpEnd[0].required = false;
this.rules.protocol[0].required = false;
// 清空出口字段
this.tunnelForm.outNodeId = null;
this.tunnelForm.outIpSta = null;
this.tunnelForm.outIpEnd = null;
this.tunnelForm.protocol = 'tls'; // 重置为默认值
}
},
@@ -749,12 +688,6 @@ export default {
handleSubmit() {
this.$refs.tunnelForm.validate(valid => {
if (valid) {
// 端口范围验证
if (this.tunnelForm.inPortSta > this.tunnelForm.inPortEnd) {
this.$message.error('入口起始端口不能大于结束端口');
return;
}
// 隧道转发时验证入口和出口节点不能相同
if (this.tunnelForm.type === 2 &&
this.tunnelForm.inNodeId === this.tunnelForm.outNodeId) {
@@ -762,13 +695,6 @@ export default {
return;
}
if (this.tunnelForm.type === 2 &&
this.tunnelForm.outIpSta && this.tunnelForm.outIpEnd &&
this.tunnelForm.outIpSta > this.tunnelForm.outIpEnd) {
this.$message.error('出口起始端口不能大于结束端口');
return;
}
this.submitLoading = true;
const data = { ...this.tunnelForm };
@@ -780,10 +706,6 @@ export default {
name: data.name,
flow: data.flow,
trafficRatio: data.trafficRatio,
inPortSta: data.inPortSta,
inPortEnd: data.inPortEnd,
outIpSta: data.outIpSta,
outIpEnd: data.outIpEnd,
tcpListenAddr: data.tcpListenAddr,
udpListenAddr: data.udpListenAddr
};
@@ -829,11 +751,7 @@ export default {
id: null,
name: '',
inNodeId: null,
inPortSta: 1,
inPortEnd: 65535,
outNodeId: null,
outIpSta: 1,
outIpEnd: 65535,
type: 1,
flow: 1, // 默认单向计算
status: 1, // 默认启用
+160 -491
View File
@@ -37,6 +37,11 @@
<i class="el-icon-position"></i>
<span>{{ node.serverIp }}</span>
</div>
<div class="version-info">
<i class="el-icon-document"></i>
<span>版本: {{ node.version || '未知' }}</span>
</div>
</div>
<!-- 监控指标网格 -->
@@ -179,95 +184,47 @@
></el-input>
</el-form-item>
<el-form-item label="入口IP" prop="ipList">
<div class="ip-input-container">
<!-- IP输入区域 -->
<div class="ip-input-section">
<el-input
v-model="newIpInput"
placeholder="输入IP地址或域名,按Enter添加"
clearable
@keyup.enter.native="addIp"
size="medium"
class="ip-input"
:disabled="nodeForm.ipList.length >= 10"
>
<template slot="append">
<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>
<el-form-item label="入口IP" prop="ipString">
<el-input
v-model="nodeForm.ipString"
type="textarea"
:rows="4"
placeholder="一行一个IP地址或域名"
clearable
>
</el-input>
<div class="form-hint">
格式: IPv4、IPv6地址或域名<br>
支持多个IP,每行一个地址,例如:<br>
192.168.1.100<br>
2001:db8::1<br>
example.com
</div>
</el-form-item>
<el-divider content-position="left">端口配置</el-divider>
<el-form-item label="起始端口" prop="portSta">
<el-input-number
v-model="nodeForm.portSta"
:min="1"
:max="65535"
placeholder="起始端口"
style="width: 100%"
@change="handlePortStartChange"
></el-input-number>
</el-form-item>
<el-form-item label="结束端口" prop="portEnd">
<el-input-number
v-model="nodeForm.portEnd"
:min="1"
:max="65535"
placeholder="结束端口"
style="width: 100%"
@change="handlePortEndChange"
></el-input-number>
</el-form-item>
<el-alert
title="服务器ip是用于隧道转发时,入口服务器将数据转发到此服务器"
@@ -283,6 +240,13 @@
show-icon
style="margin-bottom: 20px;">
</el-alert>
<el-alert
title="端口范围设定该节点可使用的端口区间,用于隧道和转发时的端口分配"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px;">
</el-alert>
<el-alert
title="系统将自动为节点生成UUID密钥"
type="info"
@@ -341,26 +305,45 @@ export default {
nodeForm: {
id: null,
name: '',
ipList: [],
serverIp: ''
ipString: '',
serverIp: '',
portSta: 1000,
portEnd: 65535
},
newIpInput: '',
rules: {
name: [
{ required: true, message: '请输入节点名称', trigger: 'blur' },
{ min: 2, message: '节点名称长度至少2位', trigger: 'blur' },
{ max: 50, message: '节点名称长度不能超过50位', trigger: 'blur' }
],
ipList: [
ipString: [
{ required: true, message: '请输入入口IP地址', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (!value || value.length === 0) {
callback(new Error('请至少添加一个入口IP地址'));
if (value) {
// 按行分割IP地址
const ips = value.split('\n').map(ip => ip.trim()).filter(ip => ip);
if (ips.length === 0) {
callback(new Error('请输入至少一个有效IP地址'));
return;
}
// 验证每个IP地址
for (let i = 0; i < ips.length; i++) {
const ip = ips[i];
if (!this.validateIp(ip)) {
callback(new Error(`第${i + 1}行IP地址格式错误: ${ip}\n请使用正确格式,如: 192.168.1.100 或 2001:db8::1 或 example.com`));
return;
}
}
callback();
} else {
callback();
}
},
trigger: 'change'
trigger: 'blur'
}
],
serverIp: [
@@ -377,6 +360,24 @@ export default {
},
trigger: 'blur'
}
],
portSta: [
{ required: true, message: '请输入起始端口', trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: '端口范围必须在1-65535之间', trigger: 'blur' }
],
portEnd: [
{ required: true, message: '请输入结束端口', trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: '端口范围必须在1-65535之间', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value && this.nodeForm.portSta && value < this.nodeForm.portSta) {
callback(new Error('结束端口不能小于起始端口'));
} else {
callback();
}
},
trigger: 'blur'
}
]
},
websocket: null,
@@ -465,8 +466,10 @@ export default {
this.nodeForm = {
id: node.id,
name: node.name,
ipList: node.ip ? (typeof node.ip === 'string' ? node.ip.split(',').filter(ip => ip.trim()) : []) : [],
serverIp: node.serverIp || ''
ipString: node.ip ? (typeof node.ip === 'string' ? node.ip.split(',').map(ip => ip.trim()).join('\n') : '') : '',
serverIp: node.serverIp || '',
portSta: node.portSta,
portEnd: node.portEnd
};
this.dialogVisible = true;
},
@@ -524,15 +527,23 @@ export default {
this.submitLoading = true;
const apiCall = this.isEdit ? updateNode : createNode;
const ipString = this.nodeForm.ipString
.split('\n')
.map(ip => ip.trim())
.filter(ip => ip)
.join(',');
const submitData = {
...this.nodeForm,
ip: this.nodeForm.ipList.join(',') // 将IP数组转换为逗号分隔的字符串
ip: ipString // 将多行IP字符串转换为逗号分隔的字符串
};
delete submitData.ipList; // 移除ipList字段
delete submitData.ipString; // 移除ipString字段
const data = this.isEdit ? submitData : {
name: this.nodeForm.name,
ip: this.nodeForm.ipList.join(','),
serverIp: this.nodeForm.serverIp
ip: ipString,
serverIp: this.nodeForm.serverIp,
portSta: this.nodeForm.portSta,
portEnd: this.nodeForm.portEnd
};
apiCall(data).then(res => {
@@ -546,8 +557,10 @@ export default {
const existingNode = this.nodeList.find(n => n.id === this.nodeForm.id);
if (existingNode) {
existingNode.name = this.nodeForm.name;
existingNode.ip = this.nodeForm.ipList.join(','); // 正确设置IP字段
existingNode.ip = ipString; // 正确设置IP字段
existingNode.serverIp = this.nodeForm.serverIp; // 更新服务器IP
existingNode.portSta = this.nodeForm.portSta; // 更新起始端口
existingNode.portEnd = this.nodeForm.portEnd; // 更新结束端口
existingNode.updatedTime = Date.now(); // 更新时间戳
}
} else {
@@ -601,10 +614,11 @@ export default {
this.nodeForm = {
id: null,
name: '',
ipList: [],
serverIp: ''
ipString: '',
serverIp: '',
portSta: 1000,
portEnd: 65535
};
this.newIpInput = '';
if (this.$refs.nodeForm) {
this.$refs.nodeForm.clearValidate();
}
@@ -872,146 +886,7 @@ export default {
return false;
},
// 添加IP地址
addIp() {
if (!this.newIpInput || !this.newIpInput.trim()) return;
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({
message: '请输入有效的IP地址或域名(支持IPv4、IPv6和域名格式)',
type: 'error',
duration: 3000,
showClose: true
});
return;
}
// 检查是否已存在
if (this.nodeForm.ipList.includes(ip)) {
this.$message({
message: '该IP地址已存在',
type: 'warning',
duration: 3000,
showClose: true
});
return;
}
// 添加到列表
this.nodeForm.ipList.push(ip);
this.newIpInput = '';
// 触发表单验证
this.$nextTick(() => {
if (this.$refs.nodeForm) {
this.$refs.nodeForm.validateField('ipList');
}
});
},
// 移除IP地址
removeIp(index) {
const removedIp = this.nodeForm.ipList[index];
this.nodeForm.ipList.splice(index, 1);
// 触发表单验证
this.$nextTick(() => {
if (this.$refs.nodeForm) {
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验证)
validateSingleIp(ip) {
@@ -1077,22 +952,25 @@ export default {
}
},
// 获取IP标签类型(根据IP类型设置不同颜色)
getIpTagType(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 'primary'; // IPv4 - 蓝色
} else if (ipv6Regex.test(ip)) {
return 'success'; // IPv6 - 绿色
} else {
return 'warning'; // 域名 - 橙色
}
}
// 端口起始值变化处理
handlePortStartChange() {
this.$nextTick(() => {
if (this.$refs.nodeForm) {
this.$refs.nodeForm.validateField('portEnd');
}
});
},
// 端口结束值变化处理
handlePortEndChange() {
this.$nextTick(() => {
if (this.$refs.nodeForm) {
this.$refs.nodeForm.validateField('portEnd');
}
});
},
}
};
</script>
@@ -1213,6 +1091,20 @@ export default {
font-size: 12px;
}
.version-info {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.version-info i {
color: #67c23a;
font-size: 12px;
}
/* 指标网格 */
.metrics-grid {
display: grid;
@@ -1378,6 +1270,10 @@ export default {
font-size: 11px;
}
.version-info {
font-size: 11px;
}
.metrics-grid {
gap: 6px;
margin-bottom: 10px;
@@ -1425,239 +1321,12 @@ export default {
/* IP输入组件样式 */
.ip-input-container {
width: 100%;
}
/* IP输入区域 */
.ip-input-section {
margin-bottom: 16px;
}
.ip-input {
width: 100%;
margin-bottom: 8px;
}
.ip-input-tips {
display: flex;
justify-content: space-between;
align-items: center;
/* 表单提示样式 */
.form-hint {
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;
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-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;
margin-right: 4px;
}
.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-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-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;
}
margin-top: 5px;
line-height: 1.4;
}
/* 复制对话框样式 */