diff --git a/springboot-backend/src/main/java/com/admin/common/dto/BatchChangeTunnelDto.java b/springboot-backend/src/main/java/com/admin/common/dto/BatchChangeTunnelDto.java new file mode 100644 index 0000000..19923f3 --- /dev/null +++ b/springboot-backend/src/main/java/com/admin/common/dto/BatchChangeTunnelDto.java @@ -0,0 +1,16 @@ +package com.admin.common.dto; + +import lombok.Data; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +@Data +public class BatchChangeTunnelDto { + + @NotEmpty(message = "转发ID列表不能为空") + private List forwardIds; + + @NotNull(message = "目标隧道ID不能为空") + private Long targetTunnelId; +} diff --git a/springboot-backend/src/main/java/com/admin/common/dto/BatchDeleteDto.java b/springboot-backend/src/main/java/com/admin/common/dto/BatchDeleteDto.java new file mode 100644 index 0000000..46e31d4 --- /dev/null +++ b/springboot-backend/src/main/java/com/admin/common/dto/BatchDeleteDto.java @@ -0,0 +1,12 @@ +package com.admin.common.dto; + +import lombok.Data; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +@Data +public class BatchDeleteDto { + + @NotEmpty(message = "ID列表不能为空") + private List ids; +} diff --git a/springboot-backend/src/main/java/com/admin/common/dto/BatchOperationResultDto.java b/springboot-backend/src/main/java/com/admin/common/dto/BatchOperationResultDto.java new file mode 100644 index 0000000..37b87e1 --- /dev/null +++ b/springboot-backend/src/main/java/com/admin/common/dto/BatchOperationResultDto.java @@ -0,0 +1,39 @@ +package com.admin.common.dto; + +import lombok.Data; +import java.util.List; +import java.util.ArrayList; + +@Data +public class BatchOperationResultDto { + + private int successCount; + private int failCount; + private List failedItems = new ArrayList<>(); + + @Data + public static class FailedItem { + private Long id; + private String reason; + + public FailedItem() {} + + public FailedItem(Long id, String reason) { + this.id = id; + this.reason = reason; + } + } + + public void addFailedItem(Long id, String reason) { + this.failedItems.add(new FailedItem(id, reason)); + this.failCount++; + } + + public void incrementSuccess() { + this.successCount++; + } + + public boolean isAllSuccess() { + return failCount == 0; + } +} diff --git a/springboot-backend/src/main/java/com/admin/common/dto/BatchRedeployDto.java b/springboot-backend/src/main/java/com/admin/common/dto/BatchRedeployDto.java new file mode 100644 index 0000000..27c6fae --- /dev/null +++ b/springboot-backend/src/main/java/com/admin/common/dto/BatchRedeployDto.java @@ -0,0 +1,12 @@ +package com.admin.common.dto; + +import lombok.Data; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +@Data +public class BatchRedeployDto { + + @NotEmpty(message = "ID列表不能为空") + private List ids; +} diff --git a/springboot-backend/src/main/java/com/admin/controller/ForwardController.java b/springboot-backend/src/main/java/com/admin/controller/ForwardController.java index e65fafa..58591e8 100644 --- a/springboot-backend/src/main/java/com/admin/controller/ForwardController.java +++ b/springboot-backend/src/main/java/com/admin/controller/ForwardController.java @@ -1,10 +1,13 @@ package com.admin.controller; -import com.admin.common.aop.LogAnnotation; -import com.admin.common.annotation.RequireRole; -import com.admin.common.dto.ForwardDto; -import com.admin.common.dto.ForwardUpdateDto; -import com.admin.common.lang.R; +import com.admin.common.aop.LogAnnotation; +import com.admin.common.annotation.RequireRole; +import com.admin.common.dto.BatchDeleteDto; +import com.admin.common.dto.BatchRedeployDto; +import com.admin.common.dto.BatchChangeTunnelDto; +import com.admin.common.dto.ForwardDto; +import com.admin.common.dto.ForwardUpdateDto; +import com.admin.common.lang.R; import com.admin.service.ForwardService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; @@ -91,10 +94,28 @@ public class ForwardController extends BaseController { * @param params 包含forwards数组的参数,每个元素包含id和inx * @return 更新结果 */ - @LogAnnotation - @PostMapping("/update-order") - public R updateForwardOrder(@RequestBody Map params) { - return forwardService.updateForwardOrder(params); - } - -} + @LogAnnotation + @PostMapping("/update-order") + public R updateForwardOrder(@RequestBody Map params) { + return forwardService.updateForwardOrder(params); + } + + @LogAnnotation + @PostMapping("/batch-delete") + public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) { + return forwardService.batchDeleteForwards(batchDeleteDto); + } + + @LogAnnotation + @PostMapping("/batch-redeploy") + public R batchRedeploy(@Validated @RequestBody BatchRedeployDto batchRedeployDto) { + return forwardService.batchRedeployForwards(batchRedeployDto); + } + + @LogAnnotation + @PostMapping("/batch-change-tunnel") + public R batchChangeTunnel(@Validated @RequestBody BatchChangeTunnelDto batchChangeTunnelDto) { + return forwardService.batchChangeTunnel(batchChangeTunnelDto); + } + +} diff --git a/springboot-backend/src/main/java/com/admin/controller/NodeController.java b/springboot-backend/src/main/java/com/admin/controller/NodeController.java index 3e7c1a8..131a6e8 100644 --- a/springboot-backend/src/main/java/com/admin/controller/NodeController.java +++ b/springboot-backend/src/main/java/com/admin/controller/NodeController.java @@ -1,13 +1,14 @@ -package com.admin.controller; - - -import com.admin.common.annotation.RequireRole; -import com.admin.common.aop.LogAnnotation; -import com.admin.common.dto.NodeDto; -import com.admin.common.dto.NodeUpdateDto; -import com.admin.common.lang.R; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; +package com.admin.controller; + + +import com.admin.common.annotation.RequireRole; +import com.admin.common.aop.LogAnnotation; +import com.admin.common.dto.BatchDeleteDto; +import com.admin.common.dto.NodeDto; +import com.admin.common.dto.NodeUpdateDto; +import com.admin.common.lang.R; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; import java.util.Map; @@ -74,4 +75,11 @@ public class NodeController extends BaseController { return nodeService.updateNodeOrder(params); } + @LogAnnotation + @RequireRole + @PostMapping("/batch-delete") + public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) { + return nodeService.batchDeleteNodes(batchDeleteDto); + } + } diff --git a/springboot-backend/src/main/java/com/admin/controller/TunnelController.java b/springboot-backend/src/main/java/com/admin/controller/TunnelController.java index 13c4e64..5c797d2 100644 --- a/springboot-backend/src/main/java/com/admin/controller/TunnelController.java +++ b/springboot-backend/src/main/java/com/admin/controller/TunnelController.java @@ -2,6 +2,8 @@ package com.admin.controller; import com.admin.common.aop.LogAnnotation; import com.admin.common.annotation.RequireRole; +import com.admin.common.dto.BatchDeleteDto; +import com.admin.common.dto.BatchRedeployDto; import com.admin.common.dto.TunnelDto; import com.admin.common.dto.TunnelUpdateDto; @@ -157,4 +159,18 @@ public class TunnelController extends BaseController { return tunnelService.updateTunnelOrder(params); } + @LogAnnotation + @RequireRole + @PostMapping("/batch-delete") + public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) { + return tunnelService.batchDeleteTunnels(batchDeleteDto); + } + + @LogAnnotation + @RequireRole + @PostMapping("/batch-redeploy") + public R batchRedeploy(@Validated @RequestBody BatchRedeployDto batchRedeployDto) { + return tunnelService.batchRedeployTunnels(batchRedeployDto); + } + } diff --git a/springboot-backend/src/main/java/com/admin/service/ForwardService.java b/springboot-backend/src/main/java/com/admin/service/ForwardService.java index 6e7ef1c..f942f26 100644 --- a/springboot-backend/src/main/java/com/admin/service/ForwardService.java +++ b/springboot-backend/src/main/java/com/admin/service/ForwardService.java @@ -1,5 +1,8 @@ package com.admin.service; +import com.admin.common.dto.BatchDeleteDto; +import com.admin.common.dto.BatchRedeployDto; +import com.admin.common.dto.BatchChangeTunnelDto; import com.admin.common.dto.ForwardDto; import com.admin.common.dto.ForwardUpdateDto; import com.admin.common.lang.R; @@ -80,4 +83,10 @@ public interface ForwardService extends IService { * @return 更新结果 */ R updateForwardOrder(Map params); + + R batchDeleteForwards(BatchDeleteDto batchDeleteDto); + + R batchRedeployForwards(BatchRedeployDto batchRedeployDto); + + R batchChangeTunnel(BatchChangeTunnelDto batchChangeTunnelDto); } diff --git a/springboot-backend/src/main/java/com/admin/service/NodeService.java b/springboot-backend/src/main/java/com/admin/service/NodeService.java index b3440cb..1299315 100644 --- a/springboot-backend/src/main/java/com/admin/service/NodeService.java +++ b/springboot-backend/src/main/java/com/admin/service/NodeService.java @@ -1,8 +1,9 @@ -package com.admin.service; - -import com.admin.common.dto.NodeDto; -import com.admin.common.dto.NodeUpdateDto; -import com.admin.common.lang.R; +package com.admin.service; + +import com.admin.common.dto.BatchDeleteDto; +import com.admin.common.dto.NodeDto; +import com.admin.common.dto.NodeUpdateDto; +import com.admin.common.lang.R; import com.admin.entity.Node; import com.baomidou.mybatisplus.extension.service.IService; @@ -34,4 +35,6 @@ public interface NodeService extends IService { */ R updateNodeOrder(Map params); + R batchDeleteNodes(BatchDeleteDto batchDeleteDto); + } diff --git a/springboot-backend/src/main/java/com/admin/service/TunnelService.java b/springboot-backend/src/main/java/com/admin/service/TunnelService.java index 6bd4b97..ca83377 100644 --- a/springboot-backend/src/main/java/com/admin/service/TunnelService.java +++ b/springboot-backend/src/main/java/com/admin/service/TunnelService.java @@ -1,9 +1,11 @@ -package com.admin.service; - -import com.admin.common.dto.TunnelDto; -import com.admin.common.dto.TunnelUpdateDto; - -import com.admin.common.lang.R; +package com.admin.service; + +import com.admin.common.dto.BatchDeleteDto; +import com.admin.common.dto.BatchRedeployDto; +import com.admin.common.dto.TunnelDto; +import com.admin.common.dto.TunnelUpdateDto; + +import com.admin.common.lang.R; import com.admin.entity.Tunnel; import com.baomidou.mybatisplus.extension.service.IService; @@ -64,4 +66,18 @@ public interface TunnelService extends IService { * @param params 包含tunnels数组的参数,每个元素包含id和inx */ R updateTunnelOrder(Map params); + + /** + * 批量删除隧道 + * @param batchDeleteDto 批量删除数据 + * @return 操作结果 + */ + R batchDeleteTunnels(BatchDeleteDto batchDeleteDto); + + /** + * 批量重新下发隧道配置 + * @param batchRedeployDto 批量重新下发数据 + * @return 操作结果 + */ + R batchRedeployTunnels(BatchRedeployDto batchRedeployDto); } diff --git a/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java index 0d8339c..07cc8a2 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/ForwardServiceImpl.java @@ -1244,4 +1244,222 @@ public class ForwardServiceImpl extends ServiceImpl impl private Integer toInx; } + @Override + @Transactional + public R batchDeleteForwards(BatchDeleteDto batchDeleteDto) { + UserInfo currentUser = getCurrentUserInfo(); + BatchOperationResultDto result = new BatchOperationResultDto(); + + for (Long id : batchDeleteDto.getIds()) { + try { + Forward forward = validateForwardExists(id, currentUser); + if (forward == null) { + result.addFailedItem(id, "转发不存在或无权限"); + continue; + } + + Tunnel tunnel = validateTunnel(forward.getTunnelId()); + if (tunnel == null) { + result.addFailedItem(id, "隧道不存在"); + continue; + } + + UserTunnel userTunnel = null; + if (currentUser.getRoleId() != 0) { + userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); + if (userTunnel == null) { + result.addFailedItem(id, "没有该隧道权限"); + continue; + } + } else { + userTunnel = getUserTunnel(forward.getUserId(), tunnel.getId().intValue()); + } + + List chainTunnels = chainTunnelService.list( + new QueryWrapper().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1) + ); + + boolean deleteSuccess = true; + for (ChainTunnel chainTunnel : chainTunnels) { + String serviceName = buildServiceName(forward.getId(), forward.getUserId(), userTunnel); + Node node = nodeService.getById(chainTunnel.getNodeId()); + if (node != null) { + JSONArray services = new JSONArray(); + services.add(serviceName + "_tcp"); + services.add(serviceName + "_udp"); + GostUtil.DeleteService(node.getId(), services); + } + } + + if (deleteSuccess) { + forwardPortService.remove(new QueryWrapper().eq("forward_id", id)); + this.removeById(id); + result.incrementSuccess(); + } + } catch (Exception e) { + result.addFailedItem(id, e.getMessage()); + } + } + + if (result.isAllSuccess()) { + return R.ok(result); + } else { + return R.ok("部分操作失败", result); + } + } + + @Override + @Transactional + public R batchRedeployForwards(BatchRedeployDto batchRedeployDto) { + UserInfo currentUser = getCurrentUserInfo(); + BatchOperationResultDto result = new BatchOperationResultDto(); + + for (Long id : batchRedeployDto.getIds()) { + try { + Forward forward = validateForwardExists(id, currentUser); + if (forward == null) { + result.addFailedItem(id, "转发不存在或无权限"); + continue; + } + + Tunnel tunnel = validateTunnel(forward.getTunnelId()); + if (tunnel == null) { + result.addFailedItem(id, "隧道不存在"); + continue; + } + + if (tunnel.getStatus() != 1) { + result.addFailedItem(id, "隧道已禁用"); + continue; + } + + UserPermissionResult permissionResult = checkUserPermissions(currentUser, tunnel, id); + if (permissionResult.isHasError()) { + result.addFailedItem(id, permissionResult.getErrorMessage()); + continue; + } + + List forwardPorts = forwardPortService.list( + new QueryWrapper().eq("forward_id", id) + ); + + for (ForwardPort forwardPort : forwardPorts) { + String serviceName = buildServiceName(forward.getId(), forward.getUserId(), permissionResult.getUserTunnel()); + Node node = nodeService.getById(forwardPort.getNodeId()); + if (node != null) { + GostUtil.AddAndUpdateService(serviceName, permissionResult.getLimiter(), + node, forward, forwardPort, tunnel, "UpdateService"); + } + } + + result.incrementSuccess(); + } catch (Exception e) { + result.addFailedItem(id, e.getMessage()); + } + } + + if (result.isAllSuccess()) { + return R.ok(result); + } else { + return R.ok("部分操作失败", result); + } + } + + @Override + @Transactional + public R batchChangeTunnel(BatchChangeTunnelDto batchChangeTunnelDto) { + UserInfo currentUser = getCurrentUserInfo(); + BatchOperationResultDto result = new BatchOperationResultDto(); + + Long targetTunnelId = batchChangeTunnelDto.getTargetTunnelId(); + Tunnel targetTunnel = tunnelService.getById(targetTunnelId); + if (targetTunnel == null) { + return R.err("目标隧道不存在"); + } + if (targetTunnel.getStatus() != 1) { + return R.err("目标隧道已禁用"); + } + + for (Long forwardId : batchChangeTunnelDto.getForwardIds()) { + try { + Forward forward = validateForwardExists(forwardId, currentUser); + if (forward == null) { + result.addFailedItem(forwardId, "转发不存在或无权限"); + continue; + } + + if (forward.getTunnelId().equals(targetTunnelId.intValue())) { + result.addFailedItem(forwardId, "已是目标隧道"); + continue; + } + + Tunnel oldTunnel = validateTunnel(forward.getTunnelId()); + if (oldTunnel != null) { + UserTunnel oldUserTunnel = null; + if (currentUser.getRoleId() != 0) { + oldUserTunnel = getUserTunnel(currentUser.getUserId(), oldTunnel.getId().intValue()); + } else { + oldUserTunnel = getUserTunnel(forward.getUserId(), oldTunnel.getId().intValue()); + } + + List oldChainTunnels = chainTunnelService.list( + new QueryWrapper().eq("tunnel_id", oldTunnel.getId()).eq("chain_type", 1) + ); + for (ChainTunnel chainTunnel : oldChainTunnels) { + String serviceName = buildServiceName(forward.getId(), forward.getUserId(), oldUserTunnel); + Node node = nodeService.getById(chainTunnel.getNodeId()); + if (node != null) { + JSONArray services = new JSONArray(); + services.add(serviceName + "_tcp"); + services.add(serviceName + "_udp"); + GostUtil.DeleteService(node.getId(), services); + } + } + } + + forwardPortService.remove(new QueryWrapper().eq("forward_id", forwardId)); + + forward.setTunnelId(targetTunnelId.intValue()); + forward.setUpdatedTime(System.currentTimeMillis()); + this.updateById(forward); + + UserPermissionResult permissionResult = checkUserPermissions(currentUser, targetTunnel, forwardId); + if (permissionResult.isHasError()) { + result.addFailedItem(forwardId, "切换成功但无法下发: " + permissionResult.getErrorMessage()); + continue; + } + + List newChainTunnels = chainTunnelService.list( + new QueryWrapper().eq("tunnel_id", targetTunnel.getId()).eq("chain_type", 1) + ); + List chainTunnelsWithPort = get_port(newChainTunnels, null, forwardId); + + for (ChainTunnel chainTunnel : chainTunnelsWithPort) { + ForwardPort forwardPort = new ForwardPort(); + forwardPort.setForwardId(forwardId); + forwardPort.setNodeId(chainTunnel.getNodeId()); + forwardPort.setPort(chainTunnel.getPort()); + forwardPortService.save(forwardPort); + + String serviceName = buildServiceName(forward.getId(), forward.getUserId(), permissionResult.getUserTunnel()); + Node node = nodeService.getById(chainTunnel.getNodeId()); + if (node != null) { + GostUtil.AddAndUpdateService(serviceName, permissionResult.getLimiter(), + node, forward, forwardPort, targetTunnel, "AddService"); + } + } + + result.incrementSuccess(); + } catch (Exception e) { + result.addFailedItem(forwardId, e.getMessage()); + } + } + + if (result.isAllSuccess()) { + return R.ok(result); + } else { + return R.ok("部分操作失败", result); + } + } + } diff --git a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java index e30e472..1c2d8d8 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java @@ -2,9 +2,11 @@ package com.admin.service.impl; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; -import com.admin.common.dto.GostDto; -import com.admin.common.dto.NodeDto; -import com.admin.common.dto.NodeUpdateDto; +import com.admin.common.dto.BatchDeleteDto; +import com.admin.common.dto.BatchOperationResultDto; +import com.admin.common.dto.GostDto; +import com.admin.common.dto.NodeDto; +import com.admin.common.dto.NodeUpdateDto; import com.admin.common.lang.R; import com.admin.common.utils.GostUtil; import com.admin.common.utils.WebSocketServer; @@ -479,34 +481,59 @@ public class NodeServiceImpl extends ServiceImpl implements No } - private void validatePortRange(String port) { - Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?"); - if (port == null || port.isEmpty()) { - throw new RuntimeException("可用端口不合法"); - } - String[] parts = port.split(","); - for (String part : parts) { - part = part.trim(); - if (!PORT_PATTERN.matcher(part).matches()) { - throw new RuntimeException("可用端口不合法"); - } - if (part.contains("-")) { - String[] range = part.split("-"); - int start = Integer.parseInt(range[0]); - int end = Integer.parseInt(range[1]); - if (start < 0 || end < 0 || end > 65535 || start > end) { - throw new RuntimeException("可用端口不合法"); - } - } else { - int ports = Integer.parseInt(part); - if (ports < 0 || ports > 65535) { - throw new RuntimeException("可用端口不合法"); - } - } - } - } - - - - -} + private void validatePortRange(String port) { + Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?"); + if (port == null || port.isEmpty()) { + throw new RuntimeException("可用端口不合法"); + } + String[] parts = port.split(","); + for (String part : parts) { + part = part.trim(); + if (!PORT_PATTERN.matcher(part).matches()) { + throw new RuntimeException("可用端口不合法"); + } + if (part.contains("-")) { + String[] range = part.split("-"); + int start = Integer.parseInt(range[0]); + int end = Integer.parseInt(range[1]); + if (start < 0 || end < 0 || end > 65535 || start > end) { + throw new RuntimeException("可用端口不合法"); + } + } else { + int ports = Integer.parseInt(part); + if (ports < 0 || ports > 65535) { + throw new RuntimeException("可用端口不合法"); + } + } + } + } + + @Override + @Transactional + public R batchDeleteNodes(BatchDeleteDto batchDeleteDto) { + BatchOperationResultDto result = new BatchOperationResultDto(); + + for (Long id : batchDeleteDto.getIds()) { + try { + R deleteResult = deleteNode(id); + if (deleteResult.getCode() == 0) { + result.incrementSuccess(); + } else { + result.addFailedItem(id, deleteResult.getMsg()); + } + } catch (Exception e) { + result.addFailedItem(id, e.getMessage()); + } + } + + if (result.isAllSuccess()) { + return R.ok(result); + } else { + return R.ok("部分操作失败", result); + } + } + + + + +} diff --git a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java index 314bb6f..022a252 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/TunnelServiceImpl.java @@ -1342,5 +1342,91 @@ public class TunnelServiceImpl extends ServiceImpl impleme return forwardId + "_" + userId + "_" + userTunnelId; } + @Override + @Transactional + public R batchDeleteTunnels(BatchDeleteDto batchDeleteDto) { + BatchOperationResultDto result = new BatchOperationResultDto(); + + for (Long id : batchDeleteDto.getIds()) { + try { + Tunnel tunnel = this.getById(id); + if (tunnel == null) { + result.addFailedItem(id, "隧道不存在"); + continue; + } + + List forwardList = forwardService.list(new QueryWrapper().eq("tunnel_id", id)); + for (Forward forward : forwardList) { + forwardService.deleteForward(forward.getId()); + } + forwardService.remove(new QueryWrapper().eq("tunnel_id", id)); + userTunnelService.remove(new QueryWrapper().eq("tunnel_id", id)); + this.removeById(id); + + List chainTunnels = chainTunnelService.list(new QueryWrapper().eq("tunnel_id", id)); + for (ChainTunnel chainTunnel : chainTunnels) { + if (chainTunnel.getChainType() == 1) { + GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + chainTunnel.getTunnelId()); + } else if (chainTunnel.getChainType() == 2) { + GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + chainTunnel.getTunnelId()); + JSONArray services = new JSONArray(); + services.add(chainTunnel.getTunnelId() + "_tls"); + GostUtil.DeleteService(chainTunnel.getNodeId(), services); + } else { + JSONArray services = new JSONArray(); + services.add(chainTunnel.getTunnelId() + "_tls"); + GostUtil.DeleteService(chainTunnel.getNodeId(), services); + } + } + chainTunnelService.remove(new QueryWrapper().eq("tunnel_id", id)); + + result.incrementSuccess(); + } catch (Exception e) { + result.addFailedItem(id, e.getMessage()); + } + } + + return R.ok(result); + } + + @Override + @Transactional + public R batchRedeployTunnels(BatchRedeployDto batchRedeployDto) { + BatchOperationResultDto result = new BatchOperationResultDto(); + + for (Long id : batchRedeployDto.getIds()) { + try { + Tunnel tunnel = this.getById(id); + if (tunnel == null) { + result.addFailedItem(id, "隧道不存在"); + continue; + } + + if (tunnel.getType() != 2) { + result.incrementSuccess(); + continue; + } + + List chainTunnels = chainTunnelService.list( + new QueryWrapper().eq("tunnel_id", id) + ); + + if (chainTunnels.isEmpty()) { + result.addFailedItem(id, "隧道配置不完整"); + continue; + } + + cleanupGostConfig(chainTunnels, id); + rebuildGostConfig(chainTunnels, tunnel); + + result.incrementSuccess(); + } catch (Exception e) { + result.addFailedItem(id, e.getMessage()); + } + } + + return R.ok(result); + } + } diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 8a53ddc..d943379 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -127,3 +127,19 @@ export const checkCaptcha = () => Network.post("/captcha/check"); export const generateCaptcha = () => Network.post(`/captcha/generate`); export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data); + +// 批量操作接口 +export const batchDeleteForwards = (ids: number[]) => + Network.post("/forward/batch-delete", { ids }); +export const batchDeleteTunnels = (ids: number[]) => + Network.post("/tunnel/batch-delete", { ids }); +export const batchDeleteNodes = (ids: number[]) => + Network.post("/node/batch-delete", { ids }); +export const batchRedeployForwards = (ids: number[]) => + Network.post("/forward/batch-redeploy", { ids }); +export const batchRedeployTunnels = (ids: number[]) => + Network.post("/tunnel/batch-redeploy", { ids }); +export const batchChangeTunnel = (data: { + forwardIds: number[]; + targetTunnelId: number; +}) => Network.post("/forward/batch-change-tunnel", data); diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 876b7c1..f549ba2 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner"; import { Switch } from "@heroui/switch"; import { Alert } from "@heroui/alert"; import { Accordion, AccordionItem } from "@heroui/accordion"; +import { Checkbox } from "@heroui/checkbox"; import toast from "react-hot-toast"; import { DndContext, @@ -47,6 +48,9 @@ import { resumeForwardService, diagnoseForward, updateForwardOrder, + batchDeleteForwards, + batchRedeployForwards, + batchChangeTunnel, } from "@/api"; import { JwtUtil } from "@/utils/jwt"; @@ -214,6 +218,14 @@ export default function ForwardPage() { // 表单验证错误 const [errors, setErrors] = useState<{ [key: string]: string }>({}); + // 批量操作相关状态 + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); + const [batchChangeTunnelModalOpen, setBatchChangeTunnelModalOpen] = useState(false); + const [batchTargetTunnelId, setBatchTargetTunnelId] = useState(null); + const [batchLoading, setBatchLoading] = useState(false); + useEffect(() => { loadData(); }, []); @@ -1260,6 +1272,113 @@ export default function ForwardPage() { } }; + const toggleSelectMode = () => { + setSelectMode(!selectMode); + if (selectMode) { + setSelectedIds(new Set()); + } + }; + + const toggleSelect = (id: number) => { + const newSet = new Set(selectedIds); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + setSelectedIds(newSet); + }; + + const selectAll = () => { + const allIds = sortedForwards.map((f) => f.id); + setSelectedIds(new Set(allIds)); + }; + + const deselectAll = () => { + setSelectedIds(new Set()); + }; + + const handleBatchDelete = async () => { + if (selectedIds.size === 0) return; + setBatchLoading(true); + try { + const res = await batchDeleteForwards(Array.from(selectedIds)); + if (res.code === 0) { + const result = res.data; + if (result.failCount === 0) { + toast.success(`成功删除 ${result.successCount} 项`); + } else { + toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`); + } + setSelectedIds(new Set()); + setSelectMode(false); + setBatchDeleteModalOpen(false); + loadData(false); + } else { + toast.error(res.msg || "批量删除失败"); + } + } catch (e: any) { + toast.error(e.message || "批量删除失败"); + } finally { + setBatchLoading(false); + } + }; + + const handleBatchRedeploy = async () => { + if (selectedIds.size === 0) return; + setBatchLoading(true); + try { + const res = await batchRedeployForwards(Array.from(selectedIds)); + if (res.code === 0) { + const result = res.data; + if (result.failCount === 0) { + toast.success(`成功重新下发 ${result.successCount} 项`); + } else { + toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`); + } + setSelectedIds(new Set()); + setSelectMode(false); + loadData(false); + } else { + toast.error(res.msg || "批量重新下发失败"); + } + } catch (e: any) { + toast.error(e.message || "批量重新下发失败"); + } finally { + setBatchLoading(false); + } + }; + + const handleBatchChangeTunnel = async () => { + if (selectedIds.size === 0 || !batchTargetTunnelId) return; + setBatchLoading(true); + try { + const res = await batchChangeTunnel({ + forwardIds: Array.from(selectedIds), + targetTunnelId: batchTargetTunnelId, + }); + if (res.code === 0) { + const result = res.data; + if (result.failCount === 0) { + toast.success(`成功换隧道 ${result.successCount} 项`); + } else { + toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`); + } + setSelectedIds(new Set()); + setSelectMode(false); + setBatchChangeTunnelModalOpen(false); + setBatchTargetTunnelId(null); + loadData(false); + } else { + toast.error(res.msg || "批量换隧道失败"); + } + } catch (e: any) { + toast.error(e.message || "批量换隧道失败"); + } finally { + setBatchLoading(false); + } + }; + // 传感器配置 - 使用默认配置避免错误 const sensors = useSensors( useSensor(MouseSensor, { @@ -1388,6 +1507,13 @@ export default function ForwardPage() { >
+ {selectMode && ( + toggleSelect(forward.id)} + className="mr-2" + /> + )}

{forward.name} @@ -1654,12 +1780,58 @@ export default function ForwardPage() { 导出 + +

+ {selectMode && selectedIds.size > 0 && ( +
+ 已选择 {selectedIds.size} 项 + + + + + +
+ )} + {/* 根据显示模式渲染不同内容 */} {viewMode === "grouped" ? ( /* 按用户和隧道分组的转发列表 */ @@ -2891,6 +3063,72 @@ export default function ForwardPage() { )} + + {/* 批量删除确认模态框 */} + + + {(onClose) => ( + <> + 确认批量删除 + +

确定要删除选中的 {selectedIds.size} 项转发吗?此操作不可撤销。

+
+ + + + + + )} +
+
+ + {/* 批量换隧道模态框 */} + + + {(onClose) => ( + <> + 批量换隧道 + +

将选中的 {selectedIds.size} 项转发迁移到新隧道:

+ +
+ + + + + + )} +
+
); } diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index a61856b..8995185 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner"; import { Alert } from "@heroui/alert"; import { Progress } from "@heroui/progress"; import { Accordion, AccordionItem } from "@heroui/accordion"; +import { Checkbox } from "@heroui/checkbox"; import toast from "react-hot-toast"; import axios from "axios"; import { @@ -43,6 +44,7 @@ import { deleteNode, getNodeInstallCommand, updateNodeOrder, + batchDeleteNodes, } from "@/api"; interface Node { @@ -150,6 +152,11 @@ export default function NodePage() { }); const [errors, setErrors] = useState>({}); + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); + const [batchLoading, setBatchLoading] = useState(false); + // 安装命令相关状态 const [installCommandModal, setInstallCommandModal] = useState(false); const [installCommand, setInstallCommand] = useState(""); @@ -890,6 +897,57 @@ export default function NodePage() { } }; + // 批量操作处理函数 + const toggleSelectMode = () => { + setSelectMode((prev) => { + if (prev) { + setSelectedIds(new Set()); + } + return !prev; + }); + }; + + const toggleSelect = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const selectAll = () => { + setSelectedIds(new Set(sortedNodes.map((n) => n.id))); + }; + + const deselectAll = () => { + setSelectedIds(new Set()); + }; + + const handleBatchDelete = async () => { + if (selectedIds.size === 0) return; + setBatchLoading(true); + try { + const res = await batchDeleteNodes(Array.from(selectedIds)); + if (res.code === 0) { + toast.success(`成功删除 ${selectedIds.size} 个节点`); + setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id))); + setSelectedIds(new Set()); + setBatchDeleteModalOpen(false); + setSelectMode(false); + } else { + toast.error(res.msg || "批量删除失败"); + } + } catch (error) { + toast.error("网络错误,请重试"); + } finally { + setBatchLoading(false); + } + }; + // 传感器配置 const sensors = useSensors( useSensor(MouseSensor, { @@ -957,11 +1015,46 @@ export default function NodePage() {
- +
+ + +
+ {/* 批量操作浮动工具栏 */} + {selectMode && selectedIds.size > 0 && ( +
+ + 已选 {selectedIds.size} 项 + +
+ + + +
+
+ )} + {!wsConnected && (
-
+
+ {selectMode && ( + toggleSelect(node.id)} + /> + )}

{node.name}

@@ -1701,6 +1800,46 @@ export default function NodePage() { + + {/* 批量删除确认模态框 */} + + + {(onClose) => ( + <> + +

确认批量删除

+
+ +

+ 确定要删除选中的 {selectedIds.size} 个节点吗? +

+

+ 此操作不可恢复,请谨慎操作。 +

+
+ + + + + + )} +
+
); } diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index b2e0d9d..c744e1c 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -14,6 +14,7 @@ import { Chip } from "@heroui/chip"; import { Spinner } from "@heroui/spinner"; import { Divider } from "@heroui/divider"; import { Alert } from "@heroui/alert"; +import { Checkbox } from "@heroui/checkbox"; import toast from "react-hot-toast"; import { DndContext, @@ -41,6 +42,8 @@ import { getNodeList, diagnoseTunnel, updateTunnelOrder, + batchDeleteTunnels, + batchRedeployTunnels, } from "@/api"; interface ChainTunnel { @@ -144,6 +147,12 @@ export default function TunnelPage() { // 表单验证错误 const [errors, setErrors] = useState<{ [key: string]: string }>({}); + // 批量操作相关状态 + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); + const [batchLoading, setBatchLoading] = useState(false); + useEffect(() => { loadData(); }, []); @@ -644,6 +653,83 @@ export default function TunnelPage() { } }; + const toggleSelectMode = () => { + setSelectMode(!selectMode); + if (selectMode) { + setSelectedIds(new Set()); + } + }; + + const toggleSelect = (id: number) => { + const newSet = new Set(selectedIds); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + setSelectedIds(newSet); + }; + + const selectAll = () => { + const allIds = sortedTunnels.map((t) => t.id); + setSelectedIds(new Set(allIds)); + }; + + const deselectAll = () => { + setSelectedIds(new Set()); + }; + + const handleBatchDelete = async () => { + if (selectedIds.size === 0) return; + setBatchLoading(true); + try { + const res = await batchDeleteTunnels(Array.from(selectedIds)); + if (res.code === 0) { + const result = res.data; + if (result.failCount === 0) { + toast.success(`成功删除 ${result.successCount} 项`); + } else { + toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`); + } + setSelectedIds(new Set()); + setSelectMode(false); + setBatchDeleteModalOpen(false); + loadData(); + } else { + toast.error(res.msg || "批量删除失败"); + } + } catch (e: any) { + toast.error(e.message || "批量删除失败"); + } finally { + setBatchLoading(false); + } + }; + + const handleBatchRedeploy = async () => { + if (selectedIds.size === 0) return; + setBatchLoading(true); + try { + const res = await batchRedeployTunnels(Array.from(selectedIds)); + if (res.code === 0) { + const result = res.data; + if (result.failCount === 0) { + toast.success(`成功重新下发 ${result.successCount} 项`); + } else { + toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`); + } + setSelectedIds(new Set()); + setSelectMode(false); + loadData(); + } else { + toast.error(res.msg || "批量重新下发失败"); + } + } catch (e: any) { + toast.error(e.message || "批量重新下发失败"); + } finally { + setBatchLoading(false); + } + }; + // 传感器配置 const sensors = useSensors( useSensor(MouseSensor, { @@ -752,11 +838,50 @@ export default function TunnelPage() {
- +
+ + +
+ {selectMode && selectedIds.size > 0 && ( +
+ 已选择 {selectedIds.size} 项 + + + + +
+ )} + {/* 隧道卡片网格 */} {tunnels.length > 0 ? ( @@ -777,6 +902,13 @@ export default function TunnelPage() { >
+ {selectMode && ( + toggleSelect(tunnel.id)} + className="mr-2" + /> + )}

{tunnel.name} @@ -2377,6 +2509,31 @@ export default function TunnelPage() { )} + + + + {(onClose) => ( + <> + 确认批量删除 + +

确定要删除选中的 {selectedIds.size} 项隧道吗?此操作不可撤销,相关转发也将被删除。

+
+ + + + + + )} +
+

); }