mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
优化流程
This commit is contained in:
Binary file not shown.
+1
-1
@@ -119,7 +119,7 @@ func main() {
|
|||||||
log := xlogger.NewLogger()
|
log := xlogger.NewLogger()
|
||||||
logger.SetDefault(log)
|
logger.SetDefault(log)
|
||||||
|
|
||||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret)
|
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, "1.0.2")
|
||||||
defer wsReporter.Stop()
|
defer wsReporter.Stop()
|
||||||
|
|
||||||
service.SetHTTPReportURL(config.Addr, config.Secret)
|
service.SetHTTPReportURL(config.Addr, config.Secret)
|
||||||
|
|||||||
@@ -781,10 +781,10 @@ func getMemoryInfo() MemoryInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StartWebSocketReporterWithConfig 使用配置启动WebSocket报告器
|
// StartWebSocketReporterWithConfig 使用配置启动WebSocket报告器
|
||||||
func StartWebSocketReporterWithConfig(Addr string, Secret string) *WebSocketReporter {
|
func StartWebSocketReporterWithConfig(Addr string, Secret string, Version string) *WebSocketReporter {
|
||||||
|
|
||||||
// 构建包含本机IP的WebSocket URL
|
// 构建包含本机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)
|
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
-- https://www.phpmyadmin.net/
|
-- https://www.phpmyadmin.net/
|
||||||
--
|
--
|
||||||
-- 主机: localhost
|
-- 主机: localhost
|
||||||
-- 生成日期: 2025-06-26 14:11:52
|
-- 生成日期: 2025-07-02 11:33:42
|
||||||
-- 服务器版本: 5.7.40-log
|
-- 服务器版本: 5.7.40-log
|
||||||
-- PHP 版本: 7.4.33
|
-- PHP 版本: 7.4.33
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ CREATE TABLE `forward` (
|
|||||||
`tunnel_id` int(10) NOT NULL,
|
`tunnel_id` int(10) NOT NULL,
|
||||||
`in_port` int(10) NOT NULL,
|
`in_port` int(10) NOT NULL,
|
||||||
`out_port` int(10) DEFAULT NULL,
|
`out_port` int(10) DEFAULT NULL,
|
||||||
`remote_addr` longtext NOT NULL,
|
`remote_addr` varchar(100) NOT NULL,
|
||||||
`strategy` varchar(100) NOT NULL DEFAULT 'fifo' COMMENT '负载均衡策略',
|
`strategy` varchar(100) NOT NULL DEFAULT 'fifo',
|
||||||
`in_flow` bigint(20) NOT NULL DEFAULT '0',
|
`in_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||||
`out_flow` bigint(20) NOT NULL DEFAULT '0',
|
`out_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||||
`created_time` bigint(20) NOT NULL,
|
`created_time` bigint(20) NOT NULL,
|
||||||
@@ -54,8 +54,11 @@ CREATE TABLE `node` (
|
|||||||
`id` int(10) NOT NULL,
|
`id` int(10) NOT NULL,
|
||||||
`name` varchar(100) NOT NULL,
|
`name` varchar(100) NOT NULL,
|
||||||
`secret` varchar(100) NOT NULL,
|
`secret` varchar(100) NOT NULL,
|
||||||
`ip` longtext DEFAULT NULL,
|
`ip` longtext,
|
||||||
`server_ip` varchar(100) NOT NULL,
|
`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,
|
`created_time` bigint(20) NOT NULL,
|
||||||
`updated_time` bigint(20) DEFAULT NULL,
|
`updated_time` bigint(20) DEFAULT NULL,
|
||||||
`status` int(10) NOT NULL
|
`status` int(10) NOT NULL
|
||||||
@@ -90,12 +93,8 @@ CREATE TABLE `tunnel` (
|
|||||||
`traffic_ratio` decimal(10,1) NOT NULL DEFAULT '1.0',
|
`traffic_ratio` decimal(10,1) NOT NULL DEFAULT '1.0',
|
||||||
`in_node_id` int(10) NOT NULL,
|
`in_node_id` int(10) NOT NULL,
|
||||||
`in_ip` varchar(100) 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_node_id` int(10) NOT NULL,
|
||||||
`out_ip` varchar(100) 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,
|
`type` int(10) NOT NULL,
|
||||||
`protocol` varchar(10) NOT NULL DEFAULT 'tls',
|
`protocol` varchar(10) NOT NULL DEFAULT 'tls',
|
||||||
`flow` int(10) NOT NULL,
|
`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
|
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`
|
-- 使用表AUTO_INCREMENT `forward`
|
||||||
--
|
--
|
||||||
ALTER TABLE `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`
|
-- 使用表AUTO_INCREMENT `node`
|
||||||
--
|
--
|
||||||
ALTER TABLE `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`
|
-- 使用表AUTO_INCREMENT `speed_limit`
|
||||||
--
|
--
|
||||||
ALTER TABLE `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`
|
-- 使用表AUTO_INCREMENT `tunnel`
|
||||||
--
|
--
|
||||||
ALTER TABLE `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`
|
-- 使用表AUTO_INCREMENT `user`
|
||||||
--
|
--
|
||||||
ALTER TABLE `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`
|
-- 使用表AUTO_INCREMENT `user_tunnel`
|
||||||
--
|
--
|
||||||
ALTER TABLE `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;
|
COMMIT;
|
||||||
|
|
||||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||||
|
|||||||
@@ -458,6 +458,134 @@ PREPARE stmt FROM @sql;
|
|||||||
EXECUTE stmt;
|
EXECUTE stmt;
|
||||||
DEALLOCATE PREPARE 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(如果不存在)
|
-- tunnel 表:添加 tcp_listen_addr、udp_listen_addr、protocol(如果不存在)
|
||||||
|
|
||||||
-- tcp_listen_addr
|
-- tcp_listen_addr
|
||||||
|
|||||||
@@ -85,28 +85,4 @@ public class ForwardWithTunnelDto {
|
|||||||
private Long outFlow;
|
private Long outFlow;
|
||||||
|
|
||||||
private String strategy;
|
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不能为空")
|
@NotBlank(message = "服务器ip不能为空")
|
||||||
private String serverIp;
|
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.NotBlank;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
|
import javax.validation.constraints.Max;
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class NodeUpdateDto {
|
public class NodeUpdateDto {
|
||||||
@@ -19,4 +21,14 @@ public class NodeUpdateDto {
|
|||||||
|
|
||||||
@NotBlank(message = "服务器ip不能为空")
|
@NotBlank(message = "服务器ip不能为空")
|
||||||
private String serverIp;
|
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 = "入口节点不能为空")
|
@NotNull(message = "入口节点不能为空")
|
||||||
private Long inNodeId;
|
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
|
// 出口节点ID,当type=1时可以为空,会自动设置为入口节点ID
|
||||||
private Long outNodeId;
|
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 = "隧道类型不能为空")
|
@NotNull(message = "隧道类型不能为空")
|
||||||
private Integer type;
|
private Integer type;
|
||||||
|
|
||||||
|
|||||||
@@ -15,30 +15,15 @@ public class TunnelListDto {
|
|||||||
private String ip;
|
private String ip;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 入口端口范围开始
|
* 入口节点端口起始范围
|
||||||
*/
|
*/
|
||||||
private Integer inPortSta;
|
private Integer inNodePortSta;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 入口端口范围结束
|
* 入口节点端口结束范围
|
||||||
*/
|
*/
|
||||||
private Integer inPortEnd;
|
private Integer inNodePortEnd;
|
||||||
|
|
||||||
/**
|
|
||||||
* 出口IP
|
|
||||||
*/
|
|
||||||
private String outIp;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 出口端口范围开始
|
|
||||||
*/
|
|
||||||
private Integer outIpSta;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 出口端口范围结束
|
|
||||||
*/
|
|
||||||
private Integer outIpEnd;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 隧道类型(1-端口转发,2-隧道转发)
|
* 隧道类型(1-端口转发,2-隧道转发)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,27 +25,7 @@ public class TunnelUpdateDto {
|
|||||||
@DecimalMin(value = "0.0", message = "流量倍率不能小于0.0")
|
@DecimalMin(value = "0.0", message = "流量倍率不能小于0.0")
|
||||||
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
|
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
|
||||||
private BigDecimal trafficRatio;
|
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监听地址
|
// TCP监听地址
|
||||||
private String tcpListenAddr;
|
private String tcpListenAddr;
|
||||||
|
|
||||||
|
|||||||
@@ -84,39 +84,5 @@ public class UserTunnelWithDetailDto {
|
|||||||
private Long outFlow;
|
private Long outFlow;
|
||||||
|
|
||||||
private Integer status;
|
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;
|
ServletServerHttpRequest serverHttpRequest = (ServletServerHttpRequest) request;
|
||||||
String secret = serverHttpRequest.getServletRequest().getParameter("secret");
|
String secret = serverHttpRequest.getServletRequest().getParameter("secret");
|
||||||
String type = serverHttpRequest.getServletRequest().getParameter("type");
|
String type = serverHttpRequest.getServletRequest().getParameter("type");
|
||||||
|
String version = serverHttpRequest.getServletRequest().getParameter("version");
|
||||||
if (Objects.equals(type, "1")) {
|
if (Objects.equals(type, "1")) {
|
||||||
Node node = nodeService.getOne(new QueryWrapper<Node>().eq("secret", secret));
|
Node node = nodeService.getOne(new QueryWrapper<Node>().eq("secret", secret));
|
||||||
if (node == null) return false;
|
if (node == null) return false;
|
||||||
attributes.put("id", node.getId());
|
attributes.put("id", node.getId());
|
||||||
node.setStatus(1);
|
node.setStatus(1);
|
||||||
|
node.setVersion(version);
|
||||||
nodeService.updateById(node);
|
nodeService.updateById(node);
|
||||||
}else {
|
}else {
|
||||||
boolean b = JwtUtil.validateToken(secret);
|
boolean b = JwtUtil.validateToken(secret);
|
||||||
|
|||||||
@@ -26,4 +26,10 @@ public class Node extends BaseEntity {
|
|||||||
|
|
||||||
private String serverIp;
|
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 String inIp;
|
||||||
|
|
||||||
/**
|
|
||||||
* 入口端口开始
|
|
||||||
*/
|
|
||||||
private Integer inPortSta;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 入口端口结束
|
|
||||||
*/
|
|
||||||
private Integer inPortEnd;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 出口节点ID
|
* 出口节点ID
|
||||||
*/
|
*/
|
||||||
@@ -54,16 +44,6 @@ public class Tunnel extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
private String outIp;
|
private String outIp;
|
||||||
|
|
||||||
/**
|
|
||||||
* 出口端口开始
|
|
||||||
*/
|
|
||||||
private Integer outIpSta;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 出口端口结束
|
|
||||||
*/
|
|
||||||
private Integer outIpEnd;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 隧道类型(1-端口转发,2-隧道转发)
|
* 隧道类型(1-端口转发,2-隧道转发)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1145
-1141
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_IN_NODE_IN_USE = "该节点还有 %d 个隧道作为入口节点在使用,请先删除相关隧道";
|
||||||
private static final String ERROR_OUT_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();
|
Node node = new Node();
|
||||||
BeanUtils.copyProperties(nodeDto, node);
|
BeanUtils.copyProperties(nodeDto, node);
|
||||||
|
|
||||||
|
// 验证端口范围
|
||||||
|
validatePortRange(node.getPortSta(), node.getPortEnd());
|
||||||
|
|
||||||
// 设置默认属性
|
// 设置默认属性
|
||||||
node.setSecret(IdUtil.simpleUUID());
|
node.setSecret(IdUtil.simpleUUID());
|
||||||
node.setStatus(NODE_STATUS_ACTIVE);
|
node.setStatus(NODE_STATUS_ACTIVE);
|
||||||
@@ -212,6 +221,12 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
node.setName(nodeUpdateDto.getName());
|
node.setName(nodeUpdateDto.getName());
|
||||||
node.setIp(nodeUpdateDto.getIp());
|
node.setIp(nodeUpdateDto.getIp());
|
||||||
node.setServerIp(nodeUpdateDto.getServerIp());
|
node.setServerIp(nodeUpdateDto.getServerIp());
|
||||||
|
node.setPortSta(nodeUpdateDto.getPortSta());
|
||||||
|
node.setPortEnd(nodeUpdateDto.getPortEnd());
|
||||||
|
|
||||||
|
// 验证端口范围
|
||||||
|
validatePortRange(node.getPortSta(), node.getPortEnd());
|
||||||
|
|
||||||
node.setUpdatedTime(System.currentTimeMillis());
|
node.setUpdatedTime(System.currentTimeMillis());
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@@ -386,4 +401,33 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
long colonCount = address.chars().filter(ch -> ch == ':').count();
|
long colonCount = address.chars().filter(ch -> ch == ':').count();
|
||||||
return colonCount >= 2;
|
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;
|
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. 更新允许修改的字段
|
// 5. 更新允许修改的字段
|
||||||
existingTunnel.setName(tunnelUpdateDto.getName());
|
existingTunnel.setName(tunnelUpdateDto.getName());
|
||||||
existingTunnel.setFlow(tunnelUpdateDto.getFlow());
|
existingTunnel.setFlow(tunnelUpdateDto.getFlow());
|
||||||
existingTunnel.setInPortSta(tunnelUpdateDto.getInPortSta());
|
|
||||||
existingTunnel.setInPortEnd(tunnelUpdateDto.getInPortEnd());
|
|
||||||
|
|
||||||
// 更新流量倍率
|
// 更新流量倍率
|
||||||
if (tunnelUpdateDto.getTrafficRatio() != null) {
|
if (tunnelUpdateDto.getTrafficRatio() != null) {
|
||||||
@@ -213,14 +199,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
if (StrUtil.isNotBlank(tunnelUpdateDto.getUdpListenAddr())) {
|
if (StrUtil.isNotBlank(tunnelUpdateDto.getUdpListenAddr())) {
|
||||||
existingTunnel.setUdpListenAddr(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. 保存更新
|
// 6. 保存更新
|
||||||
boolean result = this.updateById(existingTunnel);
|
boolean result = this.updateById(existingTunnel);
|
||||||
@@ -315,51 +294,11 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
return R.ok();
|
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 隧道创建数据传输对象
|
* @param tunnelDto 隧道创建数据传输对象
|
||||||
* @return 验证结果响应
|
* @return 验证结果响应
|
||||||
*/
|
*/
|
||||||
@@ -368,17 +307,6 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
if (tunnelDto.getOutNodeId() == null) {
|
if (tunnelDto.getOutNodeId() == null) {
|
||||||
return R.err(ERROR_OUT_NODE_REQUIRED);
|
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();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,11 +328,6 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
return NodeValidationResult.error(ERROR_IN_NODE_OFFLINE);
|
return NodeValidationResult.error(ERROR_IN_NODE_OFFLINE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证入口端口范围
|
|
||||||
if (tunnelDto.getInPortSta() > tunnelDto.getInPortEnd()) {
|
|
||||||
return NodeValidationResult.error(ERROR_IN_PORT_RANGE_INVALID);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NodeValidationResult.success(inNode);
|
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) {
|
private R setupPortForwardOutParameters(Tunnel tunnel, TunnelDto tunnelDto, String server_ip) {
|
||||||
tunnel.setOutNodeId(tunnelDto.getInNodeId());
|
tunnel.setOutNodeId(tunnelDto.getInNodeId());
|
||||||
tunnel.setOutIp(server_ip);
|
tunnel.setOutIp(server_ip);
|
||||||
tunnel.setOutIpSta(tunnelDto.getInPortSta());
|
|
||||||
tunnel.setOutIpEnd(tunnelDto.getInPortEnd());
|
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,15 +440,6 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
if (outNode.getStatus() != NODE_STATUS_ONLINE) {
|
if (outNode.getStatus() != NODE_STATUS_ONLINE) {
|
||||||
return R.err(ERROR_OUT_NODE_OFFLINE);
|
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.setOutNodeId(tunnelDto.getOutNodeId());
|
||||||
tunnel.setOutIp(outNode.getServerIp());
|
tunnel.setOutIp(outNode.getServerIp());
|
||||||
@@ -684,13 +596,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
dto.setId(tunnel.getId().intValue());
|
dto.setId(tunnel.getId().intValue());
|
||||||
dto.setName(tunnel.getName());
|
dto.setName(tunnel.getName());
|
||||||
dto.setIp(tunnel.getInIp());
|
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.setType(tunnel.getType());
|
||||||
dto.setProtocol(tunnel.getProtocol());
|
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;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,7 @@
|
|||||||
f.out_flow AS outFlow,
|
f.out_flow AS outFlow,
|
||||||
t.name AS tunnelName,
|
t.name AS tunnelName,
|
||||||
t.in_ip AS inIp,
|
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 AS outIp,
|
||||||
t.out_ip_sta AS outIpSta,
|
|
||||||
t.out_ip_end AS outIpEnd,
|
|
||||||
t.type,
|
t.type,
|
||||||
t.protocol
|
t.protocol
|
||||||
FROM
|
FROM
|
||||||
@@ -55,11 +51,7 @@
|
|||||||
f.strategy AS strategy,
|
f.strategy AS strategy,
|
||||||
t.name AS tunnelName,
|
t.name AS tunnelName,
|
||||||
t.in_ip AS inIp,
|
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 AS outIp,
|
||||||
t.out_ip_sta AS outIpSta,
|
|
||||||
t.out_ip_end AS outIpEnd,
|
|
||||||
t.type,
|
t.type,
|
||||||
t.protocol
|
t.protocol
|
||||||
FROM
|
FROM
|
||||||
|
|||||||
@@ -19,11 +19,7 @@
|
|||||||
t.name as tunnelName,
|
t.name as tunnelName,
|
||||||
t.flow as tunnelFlow,
|
t.flow as tunnelFlow,
|
||||||
t.in_ip as inIp,
|
t.in_ip as inIp,
|
||||||
t.in_port_sta as inPortSta,
|
|
||||||
t.in_port_end as inPortEnd,
|
|
||||||
t.out_ip as outIp,
|
t.out_ip as outIp,
|
||||||
t.out_ip_sta as outIpSta,
|
|
||||||
t.out_ip_end as outIpEnd,
|
|
||||||
t.type,
|
t.type,
|
||||||
t.protocol,
|
t.protocol,
|
||||||
sl.name as speedLimitName,
|
sl.name as speedLimitName,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=http://localhost:6365
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package-lock=true
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 |
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SVGProps } from "react";
|
||||||
|
|
||||||
|
export type IconSvgProps = SVGProps<SVGSVGElement> & {
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
// 可以在这里添加其他设置的恢复逻辑
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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()],
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
{ "source": "/(.*)", "destination": "/" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -241,8 +241,11 @@
|
|||||||
>
|
>
|
||||||
<template slot="prepend">端口</template>
|
<template slot="prepend">端口</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<div class="form-hint" v-if="selectedTunnel">
|
<div class="form-hint" v-if="selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd">
|
||||||
允许范围: {{ selectedTunnel.inPortSta }}-{{ selectedTunnel.inPortEnd }},留空将自动分配可用端口
|
允许范围: {{ selectedTunnel.inNodePortSta }}-{{ selectedTunnel.inNodePortEnd }},留空将自动分配可用端口
|
||||||
|
</div>
|
||||||
|
<div class="form-hint" v-else-if="selectedTunnel">
|
||||||
|
已选择隧道: {{ selectedTunnel.name }},留空将自动分配可用端口
|
||||||
</div>
|
</div>
|
||||||
<div class="form-hint" v-else>
|
<div class="form-hint" v-else>
|
||||||
请先选择隧道以查看端口范围,留空将自动分配可用端口
|
请先选择隧道以查看端口范围,留空将自动分配可用端口
|
||||||
@@ -430,10 +433,10 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否在隧道允许范围内
|
// 检查是否在节点允许范围内
|
||||||
if (this.selectedTunnel) {
|
if (this.selectedTunnel && this.selectedTunnel.inNodePortSta && this.selectedTunnel.inNodePortEnd) {
|
||||||
if (value < this.selectedTunnel.inPortSta || value > this.selectedTunnel.inPortEnd) {
|
if (value < this.selectedTunnel.inNodePortSta || value > this.selectedTunnel.inNodePortEnd) {
|
||||||
callback(new Error(`端口号必须在${this.selectedTunnel.inPortSta}-${this.selectedTunnel.inPortEnd}范围内`));
|
callback(new Error(`端口号必须在${this.selectedTunnel.inNodePortSta}-${this.selectedTunnel.inNodePortEnd}范围内`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,6 +585,8 @@ export default {
|
|||||||
async handleAdd() {
|
async handleAdd() {
|
||||||
this.isEdit = false;
|
this.isEdit = false;
|
||||||
this.dialogTitle = '新增转发';
|
this.dialogTitle = '新增转发';
|
||||||
|
// 重置表单状态,确保selectedTunnel被清空
|
||||||
|
this.resetForm();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 只在没有隧道数据时才加载
|
// 只在没有隧道数据时才加载
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
@click="toggleMobileMenu"
|
@click="toggleMobileMenu"
|
||||||
type="text">
|
type="text">
|
||||||
</el-button>
|
</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>
|
||||||
<div class="header-avatar">
|
<div class="header-avatar">
|
||||||
<el-avatar style="margin-right: 10px" size="medium" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"></el-avatar>
|
<el-avatar style="margin-right: 10px" size="medium" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"></el-avatar>
|
||||||
|
|||||||
@@ -208,24 +208,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
|
|
||||||
</el-form-item>
|
</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-form-item label="监听地址" prop="tcpListenAddr">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -297,24 +280,6 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
|
|
||||||
</el-form-item>
|
</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>
|
</template>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
@@ -541,11 +506,7 @@ export default {
|
|||||||
id: null,
|
id: null,
|
||||||
name: '',
|
name: '',
|
||||||
inNodeId: null,
|
inNodeId: null,
|
||||||
inPortSta: 1,
|
|
||||||
inPortEnd: 65535,
|
|
||||||
outNodeId: null,
|
outNodeId: null,
|
||||||
outIpSta: 1,
|
|
||||||
outIpEnd: 65535,
|
|
||||||
type: 1,
|
type: 1,
|
||||||
flow: 1, // 默认单向计算
|
flow: 1, // 默认单向计算
|
||||||
status: 1, // 默认启用
|
status: 1, // 默认启用
|
||||||
@@ -563,12 +524,6 @@ export default {
|
|||||||
inNodeId: [
|
inNodeId: [
|
||||||
{ required: true, message: '请选择入口节点', trigger: 'change' }
|
{ required: true, message: '请选择入口节点', trigger: 'change' }
|
||||||
],
|
],
|
||||||
inPortSta: [
|
|
||||||
{ required: true, message: '请输入起始端口', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
inPortEnd: [
|
|
||||||
{ required: true, message: '请输入结束端口', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
type: [
|
type: [
|
||||||
{ required: true, message: '请选择隧道类型', trigger: 'change' }
|
{ required: true, message: '请选择隧道类型', trigger: 'change' }
|
||||||
],
|
],
|
||||||
@@ -583,12 +538,6 @@ export default {
|
|||||||
outNodeId: [
|
outNodeId: [
|
||||||
{ required: false, message: '请选择出口节点', trigger: 'change' }
|
{ required: false, message: '请选择出口节点', trigger: 'change' }
|
||||||
],
|
],
|
||||||
outIpSta: [
|
|
||||||
{ required: false, message: '请输入起始端口', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
outIpEnd: [
|
|
||||||
{ required: false, message: '请输入结束端口', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
protocol: [
|
protocol: [
|
||||||
{ required: false, message: '请选择协议类型', trigger: 'change' }
|
{ required: false, message: '请选择协议类型', trigger: 'change' }
|
||||||
],
|
],
|
||||||
@@ -683,10 +632,6 @@ export default {
|
|||||||
name: tunnel.name,
|
name: tunnel.name,
|
||||||
flow: tunnel.flow,
|
flow: tunnel.flow,
|
||||||
trafficRatio: tunnel.trafficRatio || 1.0,
|
trafficRatio: tunnel.trafficRatio || 1.0,
|
||||||
inPortSta: tunnel.inPortSta,
|
|
||||||
inPortEnd: tunnel.inPortEnd,
|
|
||||||
outIpSta: tunnel.outIpSta,
|
|
||||||
outIpEnd: tunnel.outIpEnd,
|
|
||||||
tcpListenAddr: tunnel.tcpListenAddr || '0.0.0.0',
|
tcpListenAddr: tunnel.tcpListenAddr || '0.0.0.0',
|
||||||
udpListenAddr: tunnel.udpListenAddr || '0.0.0.0',
|
udpListenAddr: tunnel.udpListenAddr || '0.0.0.0',
|
||||||
// 以下字段不允许修改,但需要保留原值用于显示和提交
|
// 以下字段不允许修改,但需要保留原值用于显示和提交
|
||||||
@@ -727,20 +672,14 @@ export default {
|
|||||||
if (value === 2) {
|
if (value === 2) {
|
||||||
// 隧道转发时,出口字段变为必填
|
// 隧道转发时,出口字段变为必填
|
||||||
this.rules.outNodeId[0].required = true;
|
this.rules.outNodeId[0].required = true;
|
||||||
this.rules.outIpSta[0].required = true;
|
|
||||||
this.rules.outIpEnd[0].required = true;
|
|
||||||
this.rules.protocol[0].required = true;
|
this.rules.protocol[0].required = true;
|
||||||
} else {
|
} else {
|
||||||
// 端口转发时,出口字段不必填
|
// 端口转发时,出口字段不必填
|
||||||
this.rules.outNodeId[0].required = false;
|
this.rules.outNodeId[0].required = false;
|
||||||
this.rules.outIpSta[0].required = false;
|
|
||||||
this.rules.outIpEnd[0].required = false;
|
|
||||||
this.rules.protocol[0].required = false;
|
this.rules.protocol[0].required = false;
|
||||||
|
|
||||||
// 清空出口字段
|
// 清空出口字段
|
||||||
this.tunnelForm.outNodeId = null;
|
this.tunnelForm.outNodeId = null;
|
||||||
this.tunnelForm.outIpSta = null;
|
|
||||||
this.tunnelForm.outIpEnd = null;
|
|
||||||
this.tunnelForm.protocol = 'tls'; // 重置为默认值
|
this.tunnelForm.protocol = 'tls'; // 重置为默认值
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -749,12 +688,6 @@ export default {
|
|||||||
handleSubmit() {
|
handleSubmit() {
|
||||||
this.$refs.tunnelForm.validate(valid => {
|
this.$refs.tunnelForm.validate(valid => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
// 端口范围验证
|
|
||||||
if (this.tunnelForm.inPortSta > this.tunnelForm.inPortEnd) {
|
|
||||||
this.$message.error('入口起始端口不能大于结束端口');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隧道转发时验证入口和出口节点不能相同
|
// 隧道转发时验证入口和出口节点不能相同
|
||||||
if (this.tunnelForm.type === 2 &&
|
if (this.tunnelForm.type === 2 &&
|
||||||
this.tunnelForm.inNodeId === this.tunnelForm.outNodeId) {
|
this.tunnelForm.inNodeId === this.tunnelForm.outNodeId) {
|
||||||
@@ -762,13 +695,6 @@ export default {
|
|||||||
return;
|
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;
|
this.submitLoading = true;
|
||||||
|
|
||||||
const data = { ...this.tunnelForm };
|
const data = { ...this.tunnelForm };
|
||||||
@@ -780,10 +706,6 @@ export default {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
flow: data.flow,
|
flow: data.flow,
|
||||||
trafficRatio: data.trafficRatio,
|
trafficRatio: data.trafficRatio,
|
||||||
inPortSta: data.inPortSta,
|
|
||||||
inPortEnd: data.inPortEnd,
|
|
||||||
outIpSta: data.outIpSta,
|
|
||||||
outIpEnd: data.outIpEnd,
|
|
||||||
tcpListenAddr: data.tcpListenAddr,
|
tcpListenAddr: data.tcpListenAddr,
|
||||||
udpListenAddr: data.udpListenAddr
|
udpListenAddr: data.udpListenAddr
|
||||||
};
|
};
|
||||||
@@ -829,11 +751,7 @@ export default {
|
|||||||
id: null,
|
id: null,
|
||||||
name: '',
|
name: '',
|
||||||
inNodeId: null,
|
inNodeId: null,
|
||||||
inPortSta: 1,
|
|
||||||
inPortEnd: 65535,
|
|
||||||
outNodeId: null,
|
outNodeId: null,
|
||||||
outIpSta: 1,
|
|
||||||
outIpEnd: 65535,
|
|
||||||
type: 1,
|
type: 1,
|
||||||
flow: 1, // 默认单向计算
|
flow: 1, // 默认单向计算
|
||||||
status: 1, // 默认启用
|
status: 1, // 默认启用
|
||||||
|
|||||||
+160
-491
@@ -37,6 +37,11 @@
|
|||||||
<i class="el-icon-position"></i>
|
<i class="el-icon-position"></i>
|
||||||
<span>{{ node.serverIp }}</span>
|
<span>{{ node.serverIp }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="version-info">
|
||||||
|
<i class="el-icon-document"></i>
|
||||||
|
<span>版本: {{ node.version || '未知' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 监控指标网格 -->
|
<!-- 监控指标网格 -->
|
||||||
@@ -179,95 +184,47 @@
|
|||||||
></el-input>
|
></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="入口IP" prop="ipList">
|
<el-form-item label="入口IP" prop="ipString">
|
||||||
<div class="ip-input-container">
|
<el-input
|
||||||
<!-- IP输入区域 -->
|
v-model="nodeForm.ipString"
|
||||||
<div class="ip-input-section">
|
type="textarea"
|
||||||
<el-input
|
:rows="4"
|
||||||
v-model="newIpInput"
|
placeholder="一行一个IP地址或域名"
|
||||||
placeholder="输入IP地址或域名,按Enter添加"
|
clearable
|
||||||
clearable
|
>
|
||||||
@keyup.enter.native="addIp"
|
</el-input>
|
||||||
size="medium"
|
<div class="form-hint">
|
||||||
class="ip-input"
|
格式: IPv4、IPv6地址或域名<br>
|
||||||
:disabled="nodeForm.ipList.length >= 10"
|
支持多个IP,每行一个地址,例如:<br>
|
||||||
>
|
192.168.1.100<br>
|
||||||
<template slot="append">
|
2001:db8::1<br>
|
||||||
<el-button
|
example.com
|
||||||
@click="addIp"
|
|
||||||
icon="el-icon-plus"
|
|
||||||
size="medium"
|
|
||||||
:disabled="nodeForm.ipList.length >= 10 || !newIpInput.trim()"
|
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
|
|
||||||
<!-- 添加提示 -->
|
|
||||||
<div class="ip-input-tips">
|
|
||||||
<div class="tip-item">
|
|
||||||
<i class="el-icon-info"></i>
|
|
||||||
<span>支持IPv4、IPv6地址和域名格式</span>
|
|
||||||
</div>
|
|
||||||
<div class="tip-item count-tip">
|
|
||||||
<i class="el-icon-document"></i>
|
|
||||||
<span>已添加 {{ nodeForm.ipList.length }} / 10 个IP</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 已添加的IP列表 -->
|
|
||||||
<div class="ip-list-section" v-if="nodeForm.ipList.length > 0">
|
|
||||||
<div class="ip-list-header">
|
|
||||||
<span class="list-title">
|
|
||||||
<i class="el-icon-collection-tag"></i>
|
|
||||||
已配置的入口IP
|
|
||||||
</span>
|
|
||||||
<el-button
|
|
||||||
size="mini"
|
|
||||||
type="text"
|
|
||||||
@click="clearAllIps"
|
|
||||||
class="clear-all-btn"
|
|
||||||
>
|
|
||||||
<i class="el-icon-delete"></i>
|
|
||||||
清空全部
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ip-tags-grid">
|
|
||||||
<div
|
|
||||||
v-for="(ip, index) in nodeForm.ipList"
|
|
||||||
:key="index"
|
|
||||||
class="ip-tag-item"
|
|
||||||
>
|
|
||||||
<el-tag
|
|
||||||
:type="getIpTagType(ip)"
|
|
||||||
size="medium"
|
|
||||||
closable
|
|
||||||
@close="removeIp(index)"
|
|
||||||
class="ip-tag"
|
|
||||||
>
|
|
||||||
<i :class="getIpIcon(ip)" class="ip-icon"></i>
|
|
||||||
{{ ip }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 空状态提示 -->
|
|
||||||
<div class="ip-empty-state" v-else>
|
|
||||||
<i class="el-icon-plus"></i>
|
|
||||||
<p>请添加至少一个入口IP地址</p>
|
|
||||||
<div class="example-ips">
|
|
||||||
<span class="example-label">示例:</span>
|
|
||||||
<el-tag size="mini" @click="fillExample('192.168.1.100')" class="example-tag">192.168.1.100</el-tag>
|
|
||||||
<el-tag size="mini" @click="fillExample('example.com')" class="example-tag">example.com</el-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</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
|
<el-alert
|
||||||
title="服务器ip是用于隧道转发时,入口服务器将数据转发到此服务器"
|
title="服务器ip是用于隧道转发时,入口服务器将数据转发到此服务器"
|
||||||
@@ -283,6 +240,13 @@
|
|||||||
show-icon
|
show-icon
|
||||||
style="margin-bottom: 20px;">
|
style="margin-bottom: 20px;">
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
<el-alert
|
||||||
|
title="端口范围设定该节点可使用的端口区间,用于隧道和转发时的端口分配"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 20px;">
|
||||||
|
</el-alert>
|
||||||
<el-alert
|
<el-alert
|
||||||
title="系统将自动为节点生成UUID密钥"
|
title="系统将自动为节点生成UUID密钥"
|
||||||
type="info"
|
type="info"
|
||||||
@@ -341,26 +305,45 @@ export default {
|
|||||||
nodeForm: {
|
nodeForm: {
|
||||||
id: null,
|
id: null,
|
||||||
name: '',
|
name: '',
|
||||||
ipList: [],
|
ipString: '',
|
||||||
serverIp: ''
|
serverIp: '',
|
||||||
|
portSta: 1000,
|
||||||
|
portEnd: 65535
|
||||||
},
|
},
|
||||||
newIpInput: '',
|
|
||||||
rules: {
|
rules: {
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: '请输入节点名称', trigger: 'blur' },
|
{ required: true, message: '请输入节点名称', trigger: 'blur' },
|
||||||
{ min: 2, message: '节点名称长度至少2位', trigger: 'blur' },
|
{ min: 2, message: '节点名称长度至少2位', trigger: 'blur' },
|
||||||
{ max: 50, message: '节点名称长度不能超过50位', trigger: 'blur' }
|
{ max: 50, message: '节点名称长度不能超过50位', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
ipList: [
|
ipString: [
|
||||||
|
{ required: true, message: '请输入入口IP地址', trigger: 'blur' },
|
||||||
{
|
{
|
||||||
validator: (rule, value, callback) => {
|
validator: (rule, value, callback) => {
|
||||||
if (!value || value.length === 0) {
|
if (value) {
|
||||||
callback(new Error('请至少添加一个入口IP地址'));
|
// 按行分割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 {
|
} else {
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
trigger: 'change'
|
trigger: 'blur'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
serverIp: [
|
serverIp: [
|
||||||
@@ -377,6 +360,24 @@ export default {
|
|||||||
},
|
},
|
||||||
trigger: 'blur'
|
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,
|
websocket: null,
|
||||||
@@ -465,8 +466,10 @@ export default {
|
|||||||
this.nodeForm = {
|
this.nodeForm = {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
ipList: node.ip ? (typeof node.ip === 'string' ? node.ip.split(',').filter(ip => ip.trim()) : []) : [],
|
ipString: node.ip ? (typeof node.ip === 'string' ? node.ip.split(',').map(ip => ip.trim()).join('\n') : '') : '',
|
||||||
serverIp: node.serverIp || ''
|
serverIp: node.serverIp || '',
|
||||||
|
portSta: node.portSta,
|
||||||
|
portEnd: node.portEnd
|
||||||
};
|
};
|
||||||
this.dialogVisible = true;
|
this.dialogVisible = true;
|
||||||
},
|
},
|
||||||
@@ -524,15 +527,23 @@ export default {
|
|||||||
this.submitLoading = true;
|
this.submitLoading = true;
|
||||||
|
|
||||||
const apiCall = this.isEdit ? updateNode : createNode;
|
const apiCall = this.isEdit ? updateNode : createNode;
|
||||||
|
const ipString = this.nodeForm.ipString
|
||||||
|
.split('\n')
|
||||||
|
.map(ip => ip.trim())
|
||||||
|
.filter(ip => ip)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
const submitData = {
|
const submitData = {
|
||||||
...this.nodeForm,
|
...this.nodeForm,
|
||||||
ip: this.nodeForm.ipList.join(',') // 将IP数组转换为逗号分隔的字符串
|
ip: ipString // 将多行IP字符串转换为逗号分隔的字符串
|
||||||
};
|
};
|
||||||
delete submitData.ipList; // 移除ipList字段
|
delete submitData.ipString; // 移除ipString字段
|
||||||
const data = this.isEdit ? submitData : {
|
const data = this.isEdit ? submitData : {
|
||||||
name: this.nodeForm.name,
|
name: this.nodeForm.name,
|
||||||
ip: this.nodeForm.ipList.join(','),
|
ip: ipString,
|
||||||
serverIp: this.nodeForm.serverIp
|
serverIp: this.nodeForm.serverIp,
|
||||||
|
portSta: this.nodeForm.portSta,
|
||||||
|
portEnd: this.nodeForm.portEnd
|
||||||
};
|
};
|
||||||
|
|
||||||
apiCall(data).then(res => {
|
apiCall(data).then(res => {
|
||||||
@@ -546,8 +557,10 @@ export default {
|
|||||||
const existingNode = this.nodeList.find(n => n.id === this.nodeForm.id);
|
const existingNode = this.nodeList.find(n => n.id === this.nodeForm.id);
|
||||||
if (existingNode) {
|
if (existingNode) {
|
||||||
existingNode.name = this.nodeForm.name;
|
existingNode.name = this.nodeForm.name;
|
||||||
existingNode.ip = this.nodeForm.ipList.join(','); // 正确设置IP字段
|
existingNode.ip = ipString; // 正确设置IP字段
|
||||||
existingNode.serverIp = this.nodeForm.serverIp; // 更新服务器IP
|
existingNode.serverIp = this.nodeForm.serverIp; // 更新服务器IP
|
||||||
|
existingNode.portSta = this.nodeForm.portSta; // 更新起始端口
|
||||||
|
existingNode.portEnd = this.nodeForm.portEnd; // 更新结束端口
|
||||||
existingNode.updatedTime = Date.now(); // 更新时间戳
|
existingNode.updatedTime = Date.now(); // 更新时间戳
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -601,10 +614,11 @@ export default {
|
|||||||
this.nodeForm = {
|
this.nodeForm = {
|
||||||
id: null,
|
id: null,
|
||||||
name: '',
|
name: '',
|
||||||
ipList: [],
|
ipString: '',
|
||||||
serverIp: ''
|
serverIp: '',
|
||||||
|
portSta: 1000,
|
||||||
|
portEnd: 65535
|
||||||
};
|
};
|
||||||
this.newIpInput = '';
|
|
||||||
if (this.$refs.nodeForm) {
|
if (this.$refs.nodeForm) {
|
||||||
this.$refs.nodeForm.clearValidate();
|
this.$refs.nodeForm.clearValidate();
|
||||||
}
|
}
|
||||||
@@ -872,146 +886,7 @@ export default {
|
|||||||
return false;
|
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验证)
|
// 验证单个IP地址(用于服务器IP验证)
|
||||||
validateSingleIp(ip) {
|
validateSingleIp(ip) {
|
||||||
@@ -1077,22 +952,25 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取IP标签类型(根据IP类型设置不同颜色)
|
// 端口起始值变化处理
|
||||||
getIpTagType(ip) {
|
handlePortStartChange() {
|
||||||
// IPv4格式验证
|
this.$nextTick(() => {
|
||||||
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]?)$/;
|
if (this.$refs.nodeForm) {
|
||||||
|
this.$refs.nodeForm.validateField('portEnd');
|
||||||
// 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)) {
|
handlePortEndChange() {
|
||||||
return 'success'; // IPv6 - 绿色
|
this.$nextTick(() => {
|
||||||
} else {
|
if (this.$refs.nodeForm) {
|
||||||
return 'warning'; // 域名 - 橙色
|
this.$refs.nodeForm.validateField('portEnd');
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -1213,6 +1091,20 @@ export default {
|
|||||||
font-size: 12px;
|
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 {
|
.metrics-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1378,6 +1270,10 @@ export default {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-info {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.metrics-grid {
|
.metrics-grid {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -1425,239 +1321,12 @@ export default {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* IP输入组件样式 */
|
/* 表单提示样式 */
|
||||||
.ip-input-container {
|
.form-hint {
|
||||||
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;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
margin-top: 6px;
|
margin-top: 5px;
|
||||||
}
|
line-height: 1.4;
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 复制对话框样式 */
|
/* 复制对话框样式 */
|
||||||
|
|||||||
Reference in New Issue
Block a user