Compare commits

...

6 Commits

Author SHA1 Message Date
sagit 35c8063ac5 Merge pull request #32 from Sagit-chu/opencode/kind-wolf
fix: improve batch tunnel migration and simplify batch action labels
2026-02-06 13:36:47 +08:00
root e0efadf298 feat: add batch pause/resume controls and improve batch toolbar usability 2026-02-06 05:30:50 +00:00
root a9fadfc08c fix: improve batch tunnel migration and simplify batch action labels 2026-02-06 04:00:06 +00:00
sagit 529257c8d0 Merge pull request #31 from Sagit-chu/opencode/nimble-moon
feat: add batch operations for forwards, tunnels, and nodes
2026-02-05 20:29:50 +08:00
root a667c03b6c fix: compilation errors in batch operations
- Fix R.ok() usage in backend services (remove message argument)
- Fix batchDeleteNodes call in frontend (pass array directly)
2026-02-05 11:04:33 +00:00
root 5a1fc808b2 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
2026-02-05 10:59:49 +00:00
20 changed files with 1239 additions and 96 deletions
-9
View File
@@ -1,9 +0,0 @@
---
active: true
iteration: 1
max_iterations: 100
completion_promise: "DONE"
started_at: "2026-01-24T05:20:50.887Z"
session_id: "ses_41192b7d7ffewFHxf1dDS7ESHO"
---
Complete the task as instructed
@@ -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;
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,40 @@ public class ForwardController extends BaseController {
* @param params 包含forwards数组的参数,每个元素包含id和inx
* @return 更新结果
*/
@LogAnnotation
@PostMapping("/update-order")
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
return forwardService.updateForwardOrder(params);
}
}
@LogAnnotation
@PostMapping("/update-order")
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
return forwardService.updateForwardOrder(params);
}
@LogAnnotation
@PostMapping("/batch-delete")
public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return forwardService.batchDeleteForwards(batchDeleteDto);
}
@LogAnnotation
@PostMapping("/batch-pause")
public R batchPause(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return forwardService.batchPauseForwards(batchDeleteDto);
}
@LogAnnotation
@PostMapping("/batch-resume")
public R batchResume(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return forwardService.batchResumeForwards(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;
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);
}
}
@@ -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);
}
}
@@ -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,14 @@ public interface ForwardService extends IService<Forward> {
* @return 更新结果
*/
R updateForwardOrder(Map<String, Object> params);
R batchDeleteForwards(BatchDeleteDto batchDeleteDto);
R batchPauseForwards(BatchDeleteDto batchDeleteDto);
R batchResumeForwards(BatchDeleteDto batchDeleteDto);
R batchRedeployForwards(BatchRedeployDto batchRedeployDto);
R batchChangeTunnel(BatchChangeTunnelDto batchChangeTunnelDto);
}
@@ -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<Node> {
*/
R updateNodeOrder(Map<String, Object> params);
R batchDeleteNodes(BatchDeleteDto batchDeleteDto);
}
@@ -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<Tunnel> {
* @param params 包含tunnels数组的参数,每个元素包含id和inx
*/
R updateTunnelOrder(Map<String, Object> params);
/**
* 批量删除隧道
* @param batchDeleteDto 批量删除数据
* @return 操作结果
*/
R batchDeleteTunnels(BatchDeleteDto batchDeleteDto);
/**
* 批量重新下发隧道配置
* @param batchRedeployDto 批量重新下发数据
* @return 操作结果
*/
R batchRedeployTunnels(BatchRedeployDto batchRedeployDto);
}
@@ -1244,4 +1244,269 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> 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<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());
}
}
return R.ok(result);
}
@Override
@Transactional
public R batchPauseForwards(BatchDeleteDto batchDeleteDto) {
return batchChangeForwardStatus(batchDeleteDto.getIds(), 0, "PauseService");
}
@Override
@Transactional
public R batchResumeForwards(BatchDeleteDto batchDeleteDto) {
return batchChangeForwardStatus(batchDeleteDto.getIds(), 1, "ResumeService");
}
private R batchChangeForwardStatus(List<Long> ids, int targetStatus, String gostMethod) {
BatchOperationResultDto result = new BatchOperationResultDto();
for (Long id : ids) {
try {
R changeResult = changeForwardStatus(id, targetStatus, gostMethod);
if (changeResult.getCode() == 0) {
result.incrementSuccess();
} else {
result.addFailedItem(id, changeResult.getMsg());
}
} catch (Exception e) {
result.addFailedItem(id, e.getMessage());
}
}
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());
}
}
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;
}
List<ForwardPort> existingForwardPorts = forwardPortService.list(
new QueryWrapper<ForwardPort>().eq("forward_id", forwardId).orderByAsc("id")
);
Integer originalInPort = existingForwardPorts.stream()
.map(ForwardPort::getPort)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
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 = allocatePortsForBatchTunnelChange(newChainTunnels, originalInPort, 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());
}
}
return R.ok(result);
}
private List<ChainTunnel> allocatePortsForBatchTunnelChange(List<ChainTunnel> newChainTunnels, Integer originalInPort, Long forwardId) {
if (originalInPort == null) {
return get_port(newChainTunnels, null, forwardId);
}
try {
return get_port(newChainTunnels, originalInPort, forwardId);
} catch (RuntimeException originalPortError) {
try {
return get_port(newChainTunnels, null, forwardId);
} catch (RuntimeException autoAllocateError) {
throw new RuntimeException(
"原入口端口 " + originalInPort + " 在目标隧道不可用,自动分配新端口也失败: " + autoAllocateError.getMessage(),
autoAllocateError
);
}
}
}
}
@@ -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,55 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> 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());
}
}
return R.ok(result);
}
}
@@ -1342,5 +1342,91 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> 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<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);
}
}
+4 -4
View File
@@ -55,7 +55,7 @@
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"react-is": "^19.2.4",
"react-router-dom": "6.23.0",
"react-router-dom": "6.30.3",
"recharts": "^3.1.1",
"sonner": "^2.0.6",
"tailwind-variants": "1.0.0",
@@ -64,14 +64,14 @@
"devDependencies": {
"@eslint/compat": "1.2.8",
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.25.1",
"@eslint/js": "9.39.2",
"@types/node": "^24.3.0",
"@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",
"eslint": "9.25.1",
"eslint": "9.39.2",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2",
@@ -84,7 +84,7 @@
"postcss": "8.5.6",
"prettier": "3.5.3",
"typescript": "5.6.3",
"vite": "5.4.11",
"vite": "5.4.21",
"vite-tsconfig-paths": "4.3.2"
}
}
+20
View File
@@ -127,3 +127,23 @@ 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 batchPauseForwards = (ids: number[]) =>
Network.post("/forward/batch-pause", { ids });
export const batchResumeForwards = (ids: number[]) =>
Network.post("/forward/batch-resume", { 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);
+7 -7
View File
@@ -62,7 +62,7 @@ export default function AdminLayout({
const menuItems: MenuItem[] = [
{
path: "/dashboard",
label: "仪表板",
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" />
@@ -71,7 +71,7 @@ export default function AdminLayout({
},
{
path: "/forward",
label: "转发管理",
label: "转发",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
@@ -84,7 +84,7 @@ export default function AdminLayout({
},
{
path: "/tunnel",
label: "隧道管理",
label: "隧道",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
@@ -98,7 +98,7 @@ export default function AdminLayout({
},
{
path: "/node",
label: "节点监控",
label: "节点",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
@@ -112,7 +112,7 @@ export default function AdminLayout({
},
{
path: "/limit",
label: "限速管理",
label: "限速",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
@@ -126,7 +126,7 @@ export default function AdminLayout({
},
{
path: "/user",
label: "用户管理",
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" />
@@ -136,7 +136,7 @@ export default function AdminLayout({
},
{
path: "/config",
label: "网站配置",
label: "设置",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
+294 -2
View File
@@ -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,11 @@ import {
resumeForwardService,
diagnoseForward,
updateForwardOrder,
batchDeleteForwards,
batchPauseForwards,
batchResumeForwards,
batchRedeployForwards,
batchChangeTunnel,
} from "@/api";
import { JwtUtil } from "@/utils/jwt";
@@ -214,6 +220,14 @@ export default function ForwardPage() {
// 表单验证错误
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(() => {
loadData();
}, []);
@@ -1260,6 +1274,145 @@ 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 handleBatchToggleService = async (enable: boolean) => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
try {
const ids = Array.from(selectedIds);
const res = enable
? await batchResumeForwards(ids)
: await batchPauseForwards(ids);
if (res.code === 0) {
const result = res.data;
if (result.failCount === 0) {
toast.success(
enable
? `成功启用 ${result.successCount} 项`
: `成功停用 ${result.successCount} 项`,
);
} else {
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
}
setSelectedIds(new Set());
setSelectMode(false);
loadData(false);
} else {
toast.error(res.msg || (enable ? "启用失败" : "停用失败"));
}
} catch (e: any) {
toast.error(e.message || (enable ? "启用失败" : "停用失败"));
} 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 +1541,13 @@ export default function ForwardPage() {
>
<CardHeader className="pb-2">
<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">
<h3 className="font-semibold text-foreground truncate text-sm">
{forward.name}
@@ -1605,9 +1765,9 @@ export default function ForwardPage() {
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-6 gap-2">
<div className="flex-1"></div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
{/* 显示模式切换按钮 */}
<Button
isIconOnly
@@ -1654,12 +1814,78 @@ export default function ForwardPage() {
导出
</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>
</div>
</div>
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm text-default-600 shrink-0">已选择 {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="warning"
variant="flat"
onPress={() => handleBatchToggleService(false)}
isLoading={batchLoading}
>
停用
</Button>
<Button
size="sm"
color="success"
variant="flat"
onPress={() => handleBatchToggleService(true)}
isLoading={batchLoading}
>
启用
</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>
</div>
)}
{/* 根据显示模式渲染不同内容 */}
{viewMode === "grouped" ? (
/* 按用户和隧道分组的转发列表 */
@@ -2891,6 +3117,72 @@ export default function ForwardPage() {
)}
</ModalContent>
</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>
);
}
+143 -4
View File
@@ -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<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 [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() {
<div className="flex items-center justify-between mb-6">
<div className="flex-1" />
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<div className="flex gap-2 items-center">
<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>
{/* 批量操作浮动工具栏 */}
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm font-medium shrink-0">
已选 {selectedIds.size} 项
</span>
<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 && (
<Alert
className="mb-4"
@@ -1027,7 +1120,13 @@ export default function NodePage() {
>
<CardHeader className="pb-2">
<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">
{node.name}
</h3>
@@ -1701,6 +1800,46 @@ export default function NodePage() {
</ModalFooter>
</ModalContent>
</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>
);
}
+162 -3
View File
@@ -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<Set<number>>(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,52 @@ export default function TunnelPage() {
<div className="flex items-center justify-between mb-6">
<div className="flex-1" />
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<div className="flex items-center gap-2">
<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>
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm text-default-600 shrink-0">已选择 {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>
</div>
)}
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
@@ -777,6 +904,13 @@ export default function TunnelPage() {
>
<CardHeader className="pb-2">
<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">
<h3 className="font-semibold text-foreground truncate text-sm">
{tunnel.name}
@@ -2377,6 +2511,31 @@ export default function TunnelPage() {
)}
</ModalContent>
</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>
);
}