mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-09 11:16:37 +08:00
feat: add batch operations for forwards, tunnels, and nodes
- Add batch delete, redeploy, and change-tunnel for forwards - Add batch delete and redeploy for tunnels - Add batch delete for nodes - Add multi-select UI with floating toolbar on all three pages - Create DTOs: BatchDeleteDto, BatchRedeployDto, BatchChangeTunnelDto, BatchOperationResultDto
This commit is contained in:
@@ -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<Long> forwardIds;
|
||||||
|
|
||||||
|
@NotNull(message = "目标隧道ID不能为空")
|
||||||
|
private Long targetTunnelId;
|
||||||
|
}
|
||||||
@@ -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<Long> ids;
|
||||||
|
}
|
||||||
@@ -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<FailedItem> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long> ids;
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.admin.controller;
|
package com.admin.controller;
|
||||||
|
|
||||||
import com.admin.common.aop.LogAnnotation;
|
import com.admin.common.aop.LogAnnotation;
|
||||||
import com.admin.common.annotation.RequireRole;
|
import com.admin.common.annotation.RequireRole;
|
||||||
import com.admin.common.dto.ForwardDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.ForwardUpdateDto;
|
import com.admin.common.dto.BatchRedeployDto;
|
||||||
import com.admin.common.lang.R;
|
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 com.admin.service.ForwardService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
@@ -91,10 +94,28 @@ public class ForwardController extends BaseController {
|
|||||||
* @param params 包含forwards数组的参数,每个元素包含id和inx
|
* @param params 包含forwards数组的参数,每个元素包含id和inx
|
||||||
* @return 更新结果
|
* @return 更新结果
|
||||||
*/
|
*/
|
||||||
@LogAnnotation
|
@LogAnnotation
|
||||||
@PostMapping("/update-order")
|
@PostMapping("/update-order")
|
||||||
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
|
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
|
||||||
return forwardService.updateForwardOrder(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package com.admin.controller;
|
package com.admin.controller;
|
||||||
|
|
||||||
|
|
||||||
import com.admin.common.annotation.RequireRole;
|
import com.admin.common.annotation.RequireRole;
|
||||||
import com.admin.common.aop.LogAnnotation;
|
import com.admin.common.aop.LogAnnotation;
|
||||||
import com.admin.common.dto.NodeDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.NodeUpdateDto;
|
import com.admin.common.dto.NodeDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.dto.NodeUpdateDto;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import com.admin.common.lang.R;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -74,4 +75,11 @@ public class NodeController extends BaseController {
|
|||||||
return nodeService.updateNodeOrder(params);
|
return nodeService.updateNodeOrder(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/batch-delete")
|
||||||
|
public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
|
||||||
|
return nodeService.batchDeleteNodes(batchDeleteDto);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.admin.controller;
|
|||||||
|
|
||||||
import com.admin.common.aop.LogAnnotation;
|
import com.admin.common.aop.LogAnnotation;
|
||||||
import com.admin.common.annotation.RequireRole;
|
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.TunnelDto;
|
||||||
import com.admin.common.dto.TunnelUpdateDto;
|
import com.admin.common.dto.TunnelUpdateDto;
|
||||||
|
|
||||||
@@ -157,4 +159,18 @@ public class TunnelController extends BaseController {
|
|||||||
return tunnelService.updateTunnelOrder(params);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.admin.service;
|
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.ForwardDto;
|
||||||
import com.admin.common.dto.ForwardUpdateDto;
|
import com.admin.common.dto.ForwardUpdateDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.lang.R;
|
||||||
@@ -80,4 +83,10 @@ public interface ForwardService extends IService<Forward> {
|
|||||||
* @return 更新结果
|
* @return 更新结果
|
||||||
*/
|
*/
|
||||||
R updateForwardOrder(Map<String, Object> params);
|
R updateForwardOrder(Map<String, Object> params);
|
||||||
|
|
||||||
|
R batchDeleteForwards(BatchDeleteDto batchDeleteDto);
|
||||||
|
|
||||||
|
R batchRedeployForwards(BatchRedeployDto batchRedeployDto);
|
||||||
|
|
||||||
|
R batchChangeTunnel(BatchChangeTunnelDto batchChangeTunnelDto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.admin.service;
|
package com.admin.service;
|
||||||
|
|
||||||
import com.admin.common.dto.NodeDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.NodeUpdateDto;
|
import com.admin.common.dto.NodeDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.dto.NodeUpdateDto;
|
||||||
|
import com.admin.common.lang.R;
|
||||||
import com.admin.entity.Node;
|
import com.admin.entity.Node;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
@@ -34,4 +35,6 @@ public interface NodeService extends IService<Node> {
|
|||||||
*/
|
*/
|
||||||
R updateNodeOrder(Map<String, Object> params);
|
R updateNodeOrder(Map<String, Object> params);
|
||||||
|
|
||||||
|
R batchDeleteNodes(BatchDeleteDto batchDeleteDto);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.admin.service;
|
package com.admin.service;
|
||||||
|
|
||||||
import com.admin.common.dto.TunnelDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.TunnelUpdateDto;
|
import com.admin.common.dto.BatchRedeployDto;
|
||||||
|
import com.admin.common.dto.TunnelDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.dto.TunnelUpdateDto;
|
||||||
|
|
||||||
|
import com.admin.common.lang.R;
|
||||||
import com.admin.entity.Tunnel;
|
import com.admin.entity.Tunnel;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
@@ -64,4 +66,18 @@ public interface TunnelService extends IService<Tunnel> {
|
|||||||
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||||
*/
|
*/
|
||||||
R updateTunnelOrder(Map<String, Object> params);
|
R updateTunnelOrder(Map<String, Object> params);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除隧道
|
||||||
|
* @param batchDeleteDto 批量删除数据
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
R batchDeleteTunnels(BatchDeleteDto batchDeleteDto);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量重新下发隧道配置
|
||||||
|
* @param batchRedeployDto 批量重新下发数据
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
R batchRedeployTunnels(BatchRedeployDto batchRedeployDto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1244,4 +1244,222 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
private Integer toInx;
|
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<ChainTunnel> chainTunnels = chainTunnelService.list(
|
||||||
|
new QueryWrapper<ChainTunnel>().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<ForwardPort>().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<ForwardPort> forwardPorts = forwardPortService.list(
|
||||||
|
new QueryWrapper<ForwardPort>().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<ChainTunnel> oldChainTunnels = chainTunnelService.list(
|
||||||
|
new QueryWrapper<ChainTunnel>().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<ForwardPort>().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<ChainTunnel> newChainTunnels = chainTunnelService.list(
|
||||||
|
new QueryWrapper<ChainTunnel>().eq("tunnel_id", targetTunnel.getId()).eq("chain_type", 1)
|
||||||
|
);
|
||||||
|
List<ChainTunnel> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package com.admin.service.impl;
|
|||||||
|
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.admin.common.dto.GostDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.NodeDto;
|
import com.admin.common.dto.BatchOperationResultDto;
|
||||||
import com.admin.common.dto.NodeUpdateDto;
|
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.lang.R;
|
||||||
import com.admin.common.utils.GostUtil;
|
import com.admin.common.utils.GostUtil;
|
||||||
import com.admin.common.utils.WebSocketServer;
|
import com.admin.common.utils.WebSocketServer;
|
||||||
@@ -479,34 +481,59 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void validatePortRange(String port) {
|
private void validatePortRange(String port) {
|
||||||
Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?");
|
Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?");
|
||||||
if (port == null || port.isEmpty()) {
|
if (port == null || port.isEmpty()) {
|
||||||
throw new RuntimeException("可用端口不合法");
|
throw new RuntimeException("可用端口不合法");
|
||||||
}
|
}
|
||||||
String[] parts = port.split(",");
|
String[] parts = port.split(",");
|
||||||
for (String part : parts) {
|
for (String part : parts) {
|
||||||
part = part.trim();
|
part = part.trim();
|
||||||
if (!PORT_PATTERN.matcher(part).matches()) {
|
if (!PORT_PATTERN.matcher(part).matches()) {
|
||||||
throw new RuntimeException("可用端口不合法");
|
throw new RuntimeException("可用端口不合法");
|
||||||
}
|
}
|
||||||
if (part.contains("-")) {
|
if (part.contains("-")) {
|
||||||
String[] range = part.split("-");
|
String[] range = part.split("-");
|
||||||
int start = Integer.parseInt(range[0]);
|
int start = Integer.parseInt(range[0]);
|
||||||
int end = Integer.parseInt(range[1]);
|
int end = Integer.parseInt(range[1]);
|
||||||
if (start < 0 || end < 0 || end > 65535 || start > end) {
|
if (start < 0 || end < 0 || end > 65535 || start > end) {
|
||||||
throw new RuntimeException("可用端口不合法");
|
throw new RuntimeException("可用端口不合法");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
int ports = Integer.parseInt(part);
|
int ports = Integer.parseInt(part);
|
||||||
if (ports < 0 || ports > 65535) {
|
if (ports < 0 || ports > 65535) {
|
||||||
throw new RuntimeException("可用端口不合法");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1342,5 +1342,91 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
return forwardId + "_" + userId + "_" + userTunnelId;
|
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<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("tunnel_id", id));
|
||||||
|
for (Forward forward : forwardList) {
|
||||||
|
forwardService.deleteForward(forward.getId());
|
||||||
|
}
|
||||||
|
forwardService.remove(new QueryWrapper<Forward>().eq("tunnel_id", id));
|
||||||
|
userTunnelService.remove(new QueryWrapper<UserTunnel>().eq("tunnel_id", id));
|
||||||
|
this.removeById(id);
|
||||||
|
|
||||||
|
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().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<ChainTunnel>().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<ChainTunnel> chainTunnels = chainTunnelService.list(
|
||||||
|
new QueryWrapper<ChainTunnel>().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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,3 +127,19 @@ export const checkCaptcha = () => Network.post("/captcha/check");
|
|||||||
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
||||||
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) =>
|
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) =>
|
||||||
Network.post("/captcha/verify", data);
|
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);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner";
|
|||||||
import { Switch } from "@heroui/switch";
|
import { Switch } from "@heroui/switch";
|
||||||
import { Alert } from "@heroui/alert";
|
import { Alert } from "@heroui/alert";
|
||||||
import { Accordion, AccordionItem } from "@heroui/accordion";
|
import { Accordion, AccordionItem } from "@heroui/accordion";
|
||||||
|
import { Checkbox } from "@heroui/checkbox";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -47,6 +48,9 @@ import {
|
|||||||
resumeForwardService,
|
resumeForwardService,
|
||||||
diagnoseForward,
|
diagnoseForward,
|
||||||
updateForwardOrder,
|
updateForwardOrder,
|
||||||
|
batchDeleteForwards,
|
||||||
|
batchRedeployForwards,
|
||||||
|
batchChangeTunnel,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { JwtUtil } from "@/utils/jwt";
|
import { JwtUtil } from "@/utils/jwt";
|
||||||
|
|
||||||
@@ -214,6 +218,14 @@ export default function ForwardPage() {
|
|||||||
// 表单验证错误
|
// 表单验证错误
|
||||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
// 批量操作相关状态
|
||||||
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||||
|
const [batchChangeTunnelModalOpen, setBatchChangeTunnelModalOpen] = useState(false);
|
||||||
|
const [batchTargetTunnelId, setBatchTargetTunnelId] = useState<number | null>(null);
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {
|
useSensor(MouseSensor, {
|
||||||
@@ -1388,6 +1507,13 @@ export default function ForwardPage() {
|
|||||||
>
|
>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-start w-full">
|
<div className="flex justify-between items-start w-full">
|
||||||
|
{selectMode && (
|
||||||
|
<Checkbox
|
||||||
|
isSelected={selectedIds.has(forward.id)}
|
||||||
|
onValueChange={() => toggleSelect(forward.id)}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||||
{forward.name}
|
{forward.name}
|
||||||
@@ -1654,12 +1780,58 @@ export default function ForwardPage() {
|
|||||||
导出
|
导出
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={selectMode ? "solid" : "flat"}
|
||||||
|
color={selectMode ? "warning" : "default"}
|
||||||
|
onPress={toggleSelectMode}
|
||||||
|
>
|
||||||
|
{selectMode ? "退出选择" : "批量操作"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button size="sm" variant="flat" color="primary" onPress={handleAdd}>
|
<Button size="sm" variant="flat" color="primary" onPress={handleAdd}>
|
||||||
新增
|
新增
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectMode && selectedIds.size > 0 && (
|
||||||
|
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 bg-content1 shadow-lg rounded-lg border border-divider p-3 flex items-center gap-3">
|
||||||
|
<span className="text-sm text-default-600">已选择 {selectedIds.size} 项</span>
|
||||||
|
<Button size="sm" variant="flat" onPress={selectAll}>
|
||||||
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="flat" onPress={deselectAll}>
|
||||||
|
取消全选
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setBatchDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={handleBatchRedeploy}
|
||||||
|
isLoading={batchLoading}
|
||||||
|
>
|
||||||
|
批量重新下发
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setBatchChangeTunnelModalOpen(true)}
|
||||||
|
>
|
||||||
|
批量换隧道
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 根据显示模式渲染不同内容 */}
|
{/* 根据显示模式渲染不同内容 */}
|
||||||
{viewMode === "grouped" ? (
|
{viewMode === "grouped" ? (
|
||||||
/* 按用户和隧道分组的转发列表 */
|
/* 按用户和隧道分组的转发列表 */
|
||||||
@@ -2891,6 +3063,72 @@ export default function ForwardPage() {
|
|||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 批量删除确认模态框 */}
|
||||||
|
<Modal isOpen={batchDeleteModalOpen} onOpenChange={setBatchDeleteModalOpen}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>确认批量删除</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>确定要删除选中的 {selectedIds.size} 项转发吗?此操作不可撤销。</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
onPress={handleBatchDelete}
|
||||||
|
isLoading={batchLoading}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 批量换隧道模态框 */}
|
||||||
|
<Modal isOpen={batchChangeTunnelModalOpen} onOpenChange={setBatchChangeTunnelModalOpen}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>批量换隧道</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p className="mb-4">将选中的 {selectedIds.size} 项转发迁移到新隧道:</p>
|
||||||
|
<Select
|
||||||
|
label="目标隧道"
|
||||||
|
placeholder="请选择目标隧道"
|
||||||
|
selectedKeys={batchTargetTunnelId ? [String(batchTargetTunnelId)] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected = Array.from(keys)[0];
|
||||||
|
setBatchTargetTunnelId(selected ? Number(selected) : null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tunnels.map((tunnel) => (
|
||||||
|
<SelectItem key={String(tunnel.id)}>{tunnel.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onPress={handleBatchChangeTunnel}
|
||||||
|
isLoading={batchLoading}
|
||||||
|
isDisabled={!batchTargetTunnelId}
|
||||||
|
>
|
||||||
|
确认换隧道
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner";
|
|||||||
import { Alert } from "@heroui/alert";
|
import { Alert } from "@heroui/alert";
|
||||||
import { Progress } from "@heroui/progress";
|
import { Progress } from "@heroui/progress";
|
||||||
import { Accordion, AccordionItem } from "@heroui/accordion";
|
import { Accordion, AccordionItem } from "@heroui/accordion";
|
||||||
|
import { Checkbox } from "@heroui/checkbox";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
deleteNode,
|
deleteNode,
|
||||||
getNodeInstallCommand,
|
getNodeInstallCommand,
|
||||||
updateNodeOrder,
|
updateNodeOrder,
|
||||||
|
batchDeleteNodes,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
@@ -150,6 +152,11 @@ export default function NodePage() {
|
|||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
// 安装命令相关状态
|
// 安装命令相关状态
|
||||||
const [installCommandModal, setInstallCommandModal] = useState(false);
|
const [installCommandModal, setInstallCommandModal] = useState(false);
|
||||||
const [installCommand, setInstallCommand] = useState("");
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {
|
useSensor(MouseSensor, {
|
||||||
@@ -957,11 +1015,46 @@ export default function NodePage() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
<div className="flex gap-2 items-center">
|
||||||
新增
|
<Button
|
||||||
</Button>
|
color={selectMode ? "warning" : "default"}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={toggleSelectMode}
|
||||||
|
>
|
||||||
|
{selectMode ? "取消多选" : "多选"}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 批量操作浮动工具栏 */}
|
||||||
|
{selectMode && selectedIds.size > 0 && (
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-content1 shadow-lg rounded-full px-6 py-3 flex items-center gap-4 border border-divider">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
已选 {selectedIds.size} 项
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="flat" onPress={selectAll}>
|
||||||
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="flat" onPress={deselectAll}>
|
||||||
|
取消全选
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setBatchDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!wsConnected && (
|
{!wsConnected && (
|
||||||
<Alert
|
<Alert
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
@@ -1027,7 +1120,13 @@ export default function NodePage() {
|
|||||||
>
|
>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-start w-full">
|
<div className="flex justify-between items-start w-full">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{selectMode && (
|
||||||
|
<Checkbox
|
||||||
|
isSelected={selectedIds.has(node.id)}
|
||||||
|
onValueChange={() => toggleSelect(node.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||||
{node.name}
|
{node.name}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -1701,6 +1800,46 @@ export default function NodePage() {
|
|||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 批量删除确认模态框 */}
|
||||||
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
|
isOpen={batchDeleteModalOpen}
|
||||||
|
placement="center"
|
||||||
|
scrollBehavior="outside"
|
||||||
|
size="md"
|
||||||
|
onOpenChange={setBatchDeleteModalOpen}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-xl font-bold">确认批量删除</h2>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>
|
||||||
|
确定要删除选中的 <strong>{selectedIds.size}</strong> 个节点吗?
|
||||||
|
</p>
|
||||||
|
<p className="text-small text-default-500">
|
||||||
|
此操作不可恢复,请谨慎操作。
|
||||||
|
</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
isLoading={batchLoading}
|
||||||
|
onPress={handleBatchDelete}
|
||||||
|
>
|
||||||
|
{batchLoading ? "删除中..." : "确认删除"}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Chip } from "@heroui/chip";
|
|||||||
import { Spinner } from "@heroui/spinner";
|
import { Spinner } from "@heroui/spinner";
|
||||||
import { Divider } from "@heroui/divider";
|
import { Divider } from "@heroui/divider";
|
||||||
import { Alert } from "@heroui/alert";
|
import { Alert } from "@heroui/alert";
|
||||||
|
import { Checkbox } from "@heroui/checkbox";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -41,6 +42,8 @@ import {
|
|||||||
getNodeList,
|
getNodeList,
|
||||||
diagnoseTunnel,
|
diagnoseTunnel,
|
||||||
updateTunnelOrder,
|
updateTunnelOrder,
|
||||||
|
batchDeleteTunnels,
|
||||||
|
batchRedeployTunnels,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface ChainTunnel {
|
interface ChainTunnel {
|
||||||
@@ -144,6 +147,12 @@ export default function TunnelPage() {
|
|||||||
// 表单验证错误
|
// 表单验证错误
|
||||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
// 批量操作相关状态
|
||||||
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {
|
useSensor(MouseSensor, {
|
||||||
@@ -752,11 +838,50 @@ export default function TunnelPage() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
<div className="flex items-center gap-2">
|
||||||
新增
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
|
variant={selectMode ? "solid" : "flat"}
|
||||||
|
color={selectMode ? "warning" : "default"}
|
||||||
|
onPress={toggleSelectMode}
|
||||||
|
>
|
||||||
|
{selectMode ? "退出选择" : "批量操作"}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectMode && selectedIds.size > 0 && (
|
||||||
|
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 bg-content1 shadow-lg rounded-lg border border-divider p-3 flex items-center gap-3">
|
||||||
|
<span className="text-sm text-default-600">已选择 {selectedIds.size} 项</span>
|
||||||
|
<Button size="sm" variant="flat" onPress={selectAll}>
|
||||||
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="flat" onPress={deselectAll}>
|
||||||
|
取消全选
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setBatchDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={handleBatchRedeploy}
|
||||||
|
isLoading={batchLoading}
|
||||||
|
>
|
||||||
|
批量重新下发
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 隧道卡片网格 */}
|
{/* 隧道卡片网格 */}
|
||||||
{tunnels.length > 0 ? (
|
{tunnels.length > 0 ? (
|
||||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
@@ -777,6 +902,13 @@ export default function TunnelPage() {
|
|||||||
>
|
>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-start w-full">
|
<div className="flex justify-between items-start w-full">
|
||||||
|
{selectMode && (
|
||||||
|
<Checkbox
|
||||||
|
isSelected={selectedIds.has(tunnel.id)}
|
||||||
|
onValueChange={() => toggleSelect(tunnel.id)}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||||
{tunnel.name}
|
{tunnel.name}
|
||||||
@@ -2377,6 +2509,31 @@ export default function TunnelPage() {
|
|||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={batchDeleteModalOpen} onOpenChange={setBatchDeleteModalOpen}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>确认批量删除</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>确定要删除选中的 {selectedIds.size} 项隧道吗?此操作不可撤销,相关转发也将被删除。</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
onPress={handleBatchDelete}
|
||||||
|
isLoading={batchLoading}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user