Compare commits

..

19 Commits

Author SHA1 Message Date
sagit 4d0dcf5db5 Merge pull request #35 from Sagit-chu/opencode/align-compose-installer-version
chore: align compose image tags with installer release version
2026-02-06 18:03:41 +08:00
sagit 4c756e9156 chore: align compose image tags with installer release version 2026-02-06 09:59:49 +00:00
sagit f5a40bf530 Merge pull request #33 from Sagit-chu/opencode/witty-garden
fix: migrate frontend to Vite 7 and add group
2026-02-06 17:19:05 +08:00
sagit c7dbbef0d9 Merge branch 'main' into opencode/witty-garden 2026-02-06 17:15:58 +08:00
sagit a6773fe65d fix: preserve manual tunnel access when revoking group grants 2026-02-06 09:04:27 +00:00
sagit 27a32b3ff4 fix: always create group grants for existing user tunnel pairs 2026-02-06 08:46:42 +00:00
sagit 06a45a87f6 fix: block manual removal of group-granted tunnels 2026-02-06 08:22:17 +00:00
sagit 053aef42c0 fix: use mutable map for empty grant count sync 2026-02-06 08:03:43 +00:00
sagit 9f79efd44b feat: add revocable group-based tunnel permission management 2026-02-06 07:32:14 +00:00
sagit 08ba876291 Merge pull request #34 from Sagit-chu/opencode/ghcr-log-config
chore: migrate compose images to ghcr and reduce log output
2026-02-06 15:22:28 +08:00
sagit 59af67a5b5 chore: migrate compose images to ghcr and reduce log output 2026-02-06 07:11:05 +00:00
root 65f8f7506e chore(frontend): enable rolldown-vite rust build 2026-02-06 06:27:27 +00:00
root efd5a107b9 fix: migrate frontend to Vite 7 and resolve lint warnings 2026-02-06 06:09:17 +00:00
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
71 changed files with 3898 additions and 516 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '20.19.0'
- name: Install dependencies
run: npm install --legacy-peer-deps
-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
+11 -3
View File
@@ -1,8 +1,12 @@
services:
backend:
image: bqlpfy/springboot-backend:2.0.7-beta
image: ghcr.io/sagit-chu/springboot-backend:${FLUX_VERSION:-latest}
container_name: springboot-backend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
environment:
DB_PATH: /app/data/gost.db
JWT_SECRET: ${JWT_SECRET}
@@ -25,9 +29,13 @@ services:
start_period: 60s
frontend:
image: bqlpfy/vite-frontend:2.0.7-beta
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
container_name: vite-frontend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -52,4 +60,4 @@ networks:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
- subnet: 172.20.0.0/16
+11 -3
View File
@@ -1,8 +1,12 @@
services:
backend:
image: bqlpfy/springboot-backend:2.0.7-beta
image: ghcr.io/sagit-chu/springboot-backend:${FLUX_VERSION:-latest}
container_name: springboot-backend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
environment:
DB_PATH: /app/data/gost.db
JWT_SECRET: ${JWT_SECRET}
@@ -25,9 +29,13 @@ services:
start_period: 60s
frontend:
image: bqlpfy/vite-frontend:2.0.7-beta
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
container_name: vite-frontend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -54,4 +62,4 @@ networks:
ipam:
config:
- subnet: 172.20.0.0/16
- subnet: fd00:dead:beef::/48
- subnet: fd00:dead:beef::/48
+4 -2
View File
@@ -295,6 +295,8 @@ After=network.target
WorkingDirectory=$INSTALL_DIR
ExecStart=$INSTALL_DIR/flux_agent
Restart=on-failure
StandardOutput=null
StandardError=null
[Install]
WantedBy=multi-user.target
@@ -312,8 +314,8 @@ EOF
echo "📁 配置目录: $INSTALL_DIR"
echo "🔧 服务状态: $(systemctl is-active flux_agent)"
else
echo "❌ flux_agent服务启动失败,请执行以下命令查看日志:"
echo "journalctl -u flux_agent -f"
echo "❌ flux_agent服务启动失败,请执行以下命令查看状态:"
echo "systemctl status flux_agent --no-pager"
fi
}
+39 -3
View File
@@ -78,11 +78,16 @@ resolve_version() {
return 1
}
# 根据版本号设置 compose 下载地址
set_compose_urls_by_version() {
local version="$1"
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v4.yml")
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v6.yml")
}
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
RESOLVED_VERSION=$(resolve_version) || exit 1
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v4.yml")
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v6.yml")
set_compose_urls_by_version "$RESOLVED_VERSION"
@@ -221,6 +226,27 @@ generate_random() {
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c16
}
upsert_env_var() {
local file="$1"
local key="$2"
local value="$3"
local tmp_file
tmp_file=$(mktemp)
if [ -f "$file" ]; then
awk -v k="$key" -v v="$value" '
BEGIN { found=0 }
$0 ~ ("^" k "=") { print k "=" v; found=1; next }
{ print }
END { if (!found) print k "=" v }
' "$file" > "$tmp_file"
else
printf '%s=%s\n' "$key" "$value" > "$tmp_file"
fi
mv "$tmp_file" "$file"
}
# 删除脚本自身
delete_self() {
echo ""
@@ -268,6 +294,7 @@ install_panel() {
JWT_SECRET=$JWT_SECRET
FRONTEND_PORT=$FRONTEND_PORT
BACKEND_PORT=$BACKEND_PORT
FLUX_VERSION=$RESOLVED_VERSION
EOF
echo "🚀 启动 docker 服务..."
@@ -288,6 +315,15 @@ update_panel() {
echo "🔄 开始更新面板..."
check_docker
echo "🔍 获取最新版本号..."
LATEST_VERSION=$(resolve_latest_release_tag) || {
echo "❌ 无法获取最新版本号,更新终止"
return 1
}
echo "🆕 最新版本:$LATEST_VERSION"
set_compose_urls_by_version "$LATEST_VERSION"
upsert_env_var ".env" "FLUX_VERSION" "$LATEST_VERSION"
echo "🔽 下载最新配置文件..."
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
@@ -13,6 +13,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
@MapperScan("com.admin.mapper")
public class AdminApplication {
public static void main(String[] args) {
@@ -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;
}
@@ -0,0 +1,14 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class GroupCreateDto {
@NotBlank(message = "分组名称不能为空")
private String name;
private Integer status;
}
@@ -0,0 +1,15 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
@Data
public class GroupPermissionAssignDto {
@NotNull(message = "用户分组ID不能为空")
private Long userGroupId;
@NotNull(message = "隧道分组ID不能为空")
private Long tunnelGroupId;
}
@@ -0,0 +1,13 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class GroupPermissionDetailDto {
private Long id;
private Long userGroupId;
private String userGroupName;
private Long tunnelGroupId;
private String tunnelGroupName;
private Long createdTime;
}
@@ -0,0 +1,18 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class GroupUpdateDto {
@NotNull(message = "分组ID不能为空")
private Long id;
@NotBlank(message = "分组名称不能为空")
private String name;
private Integer status;
}
@@ -0,0 +1,16 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.util.List;
@Data
public class TunnelGroupAssignTunnelsDto {
@NotNull(message = "隧道分组ID不能为空")
private Long groupId;
@NotNull(message = "隧道列表不能为空")
private List<Long> tunnelIds;
}
@@ -0,0 +1,17 @@
package com.admin.common.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class TunnelGroupDetailDto {
private Long id;
private String name;
private Integer status;
private Long createdTime;
private Long updatedTime;
private List<Long> tunnelIds = new ArrayList<>();
private List<String> tunnelNames = new ArrayList<>();
}
@@ -0,0 +1,16 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.util.List;
@Data
public class UserGroupAssignUsersDto {
@NotNull(message = "用户分组ID不能为空")
private Long groupId;
@NotNull(message = "用户列表不能为空")
private List<Long> userIds;
}
@@ -0,0 +1,17 @@
package com.admin.common.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class UserGroupDetailDto {
private Long id;
private String name;
private Integer status;
private Long createdTime;
private Long updatedTime;
private List<Long> userIds = new ArrayList<>();
private List<String> userNames = new ArrayList<>();
}
@@ -32,6 +32,19 @@ public class SqliteSchemaMigration implements ApplicationRunner {
public void run(ApplicationArguments args) {
ensureColumn("node", "inx", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tunnel", "inx", "INTEGER NOT NULL DEFAULT 0");
ensureTable("CREATE TABLE IF NOT EXISTS tunnel_group (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, created_time INTEGER NOT NULL, updated_time INTEGER NOT NULL, status INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS user_group (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, created_time INTEGER NOT NULL, updated_time INTEGER NOT NULL, status INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (id INTEGER PRIMARY KEY AUTOINCREMENT, tunnel_group_id INTEGER NOT NULL, tunnel_id INTEGER NOT NULL, created_time INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS user_group_user (id INTEGER PRIMARY KEY AUTOINCREMENT, user_group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_time INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS group_permission (id INTEGER PRIMARY KEY AUTOINCREMENT, user_group_id INTEGER NOT NULL, tunnel_group_id INTEGER NOT NULL, created_time INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS group_permission_grant (id INTEGER PRIMARY KEY AUTOINCREMENT, user_group_id INTEGER NOT NULL, tunnel_group_id INTEGER NOT NULL, user_tunnel_id INTEGER NOT NULL, created_by_group INTEGER NOT NULL DEFAULT 0, created_time INTEGER NOT NULL)");
ensureColumn("group_permission_grant", "created_by_group", "INTEGER NOT NULL DEFAULT 0");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id)");
}
private void ensureColumn(String table, String column, String columnDefinition) {
@@ -51,4 +64,8 @@ public class SqliteSchemaMigration implements ApplicationRunner {
"ALTER TABLE " + table + " ADD COLUMN " + column + " " + columnDefinition
);
}
private void ensureTable(String ddl) {
jdbcTemplate.execute(ddl);
}
}
@@ -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);
}
}
@@ -0,0 +1,123 @@
package com.admin.controller;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.annotation.RequireRole;
import com.admin.common.dto.GroupCreateDto;
import com.admin.common.dto.GroupPermissionAssignDto;
import com.admin.common.dto.GroupUpdateDto;
import com.admin.common.dto.TunnelGroupAssignTunnelsDto;
import com.admin.common.dto.UserGroupAssignUsersDto;
import com.admin.common.lang.R;
import com.admin.service.GroupService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.Map;
@RestController
@CrossOrigin
@RequestMapping("/api/v1/group")
public class GroupController {
@Resource
private GroupService groupService;
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/list")
public R tunnelGroupList() {
return groupService.getTunnelGroups();
}
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/create")
public R createTunnelGroup(@Validated @RequestBody GroupCreateDto dto) {
return groupService.createTunnelGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/update")
public R updateTunnelGroup(@Validated @RequestBody GroupUpdateDto dto) {
return groupService.updateTunnelGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/delete")
public R deleteTunnelGroup(@RequestBody Map<String, Object> params) {
Long id = Long.valueOf(params.get("id").toString());
return groupService.deleteTunnelGroup(id);
}
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/assign")
public R assignTunnels(@Validated @RequestBody TunnelGroupAssignTunnelsDto dto) {
return groupService.assignTunnelsToGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/list")
public R userGroupList() {
return groupService.getUserGroups();
}
@LogAnnotation
@RequireRole
@PostMapping("/user/create")
public R createUserGroup(@Validated @RequestBody GroupCreateDto dto) {
return groupService.createUserGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/update")
public R updateUserGroup(@Validated @RequestBody GroupUpdateDto dto) {
return groupService.updateUserGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/delete")
public R deleteUserGroup(@RequestBody Map<String, Object> params) {
Long id = Long.valueOf(params.get("id").toString());
return groupService.deleteUserGroup(id);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/assign")
public R assignUsers(@Validated @RequestBody UserGroupAssignUsersDto dto) {
return groupService.assignUsersToGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/permission/list")
public R listPermissions() {
return groupService.getGroupPermissions();
}
@LogAnnotation
@RequireRole
@PostMapping("/permission/assign")
public R assignPermission(@Validated @RequestBody GroupPermissionAssignDto dto) {
return groupService.assignGroupPermission(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/permission/remove")
public R removePermission(@RequestBody Map<String, Object> params) {
Long id = Long.valueOf(params.get("id").toString());
return groupService.removeGroupPermission(id);
}
}
@@ -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);
}
}
@@ -0,0 +1,22 @@
package com.admin.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.io.Serializable;
@Data
public class GroupPermission implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long userGroupId;
private Long tunnelGroupId;
private Long createdTime;
}
@@ -0,0 +1,26 @@
package com.admin.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.io.Serializable;
@Data
public class GroupPermissionGrant implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long userGroupId;
private Long tunnelGroupId;
private Long userTunnelId;
private Integer createdByGroup;
private Long createdTime;
}
@@ -0,0 +1,13 @@
package com.admin.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class TunnelGroup extends BaseEntity {
private static final long serialVersionUID = 1L;
private String name;
}
@@ -0,0 +1,22 @@
package com.admin.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.io.Serializable;
@Data
public class TunnelGroupTunnel implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long tunnelGroupId;
private Long tunnelId;
private Long createdTime;
}
@@ -0,0 +1,13 @@
package com.admin.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class UserGroup extends BaseEntity {
private static final long serialVersionUID = 1L;
private String name;
}
@@ -0,0 +1,22 @@
package com.admin.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.io.Serializable;
@Data
public class UserGroupUser implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long userGroupId;
private Long userId;
private Long createdTime;
}
@@ -0,0 +1,7 @@
package com.admin.mapper;
import com.admin.entity.GroupPermissionGrant;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface GroupPermissionGrantMapper extends BaseMapper<GroupPermissionGrant> {
}
@@ -0,0 +1,7 @@
package com.admin.mapper;
import com.admin.entity.GroupPermission;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface GroupPermissionMapper extends BaseMapper<GroupPermission> {
}
@@ -0,0 +1,7 @@
package com.admin.mapper;
import com.admin.entity.TunnelGroup;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface TunnelGroupMapper extends BaseMapper<TunnelGroup> {
}
@@ -0,0 +1,7 @@
package com.admin.mapper;
import com.admin.entity.TunnelGroupTunnel;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface TunnelGroupTunnelMapper extends BaseMapper<TunnelGroupTunnel> {
}
@@ -0,0 +1,7 @@
package com.admin.mapper;
import com.admin.entity.UserGroup;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface UserGroupMapper extends BaseMapper<UserGroup> {
}
@@ -0,0 +1,7 @@
package com.admin.mapper;
import com.admin.entity.UserGroupUser;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface UserGroupUserMapper extends BaseMapper<UserGroupUser> {
}
@@ -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);
}
@@ -0,0 +1,37 @@
package com.admin.service;
import com.admin.common.dto.GroupCreateDto;
import com.admin.common.dto.GroupPermissionAssignDto;
import com.admin.common.dto.GroupUpdateDto;
import com.admin.common.dto.TunnelGroupAssignTunnelsDto;
import com.admin.common.dto.UserGroupAssignUsersDto;
import com.admin.common.lang.R;
public interface GroupService {
R getTunnelGroups();
R createTunnelGroup(GroupCreateDto dto);
R updateTunnelGroup(GroupUpdateDto dto);
R deleteTunnelGroup(Long id);
R assignTunnelsToGroup(TunnelGroupAssignTunnelsDto dto);
R getUserGroups();
R createUserGroup(GroupCreateDto dto);
R updateUserGroup(GroupUpdateDto dto);
R deleteUserGroup(Long id);
R assignUsersToGroup(UserGroupAssignUsersDto dto);
R getGroupPermissions();
R assignGroupPermission(GroupPermissionAssignDto dto);
R removeGroupPermission(Long id);
}
@@ -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
);
}
}
}
}
@@ -0,0 +1,606 @@
package com.admin.service.impl;
import com.admin.common.dto.GroupCreateDto;
import com.admin.common.dto.GroupPermissionAssignDto;
import com.admin.common.dto.GroupPermissionDetailDto;
import com.admin.common.dto.GroupUpdateDto;
import com.admin.common.dto.TunnelGroupAssignTunnelsDto;
import com.admin.common.dto.TunnelGroupDetailDto;
import com.admin.common.dto.UserGroupAssignUsersDto;
import com.admin.common.dto.UserGroupDetailDto;
import com.admin.common.lang.R;
import com.admin.entity.GroupPermission;
import com.admin.entity.GroupPermissionGrant;
import com.admin.entity.Tunnel;
import com.admin.entity.TunnelGroup;
import com.admin.entity.TunnelGroupTunnel;
import com.admin.entity.User;
import com.admin.entity.UserGroup;
import com.admin.entity.UserGroupUser;
import com.admin.entity.UserTunnel;
import com.admin.mapper.GroupPermissionGrantMapper;
import com.admin.mapper.GroupPermissionMapper;
import com.admin.mapper.TunnelGroupMapper;
import com.admin.mapper.TunnelGroupTunnelMapper;
import com.admin.mapper.UserGroupMapper;
import com.admin.mapper.UserGroupUserMapper;
import com.admin.service.GroupService;
import com.admin.service.TunnelService;
import com.admin.service.UserService;
import com.admin.service.UserTunnelService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class GroupServiceImpl implements GroupService {
@Resource
private TunnelGroupMapper tunnelGroupMapper;
@Resource
private UserGroupMapper userGroupMapper;
@Resource
private TunnelGroupTunnelMapper tunnelGroupTunnelMapper;
@Resource
private UserGroupUserMapper userGroupUserMapper;
@Resource
private GroupPermissionMapper groupPermissionMapper;
@Resource
private GroupPermissionGrantMapper groupPermissionGrantMapper;
@Resource
private TunnelService tunnelService;
@Resource
private UserService userService;
@Resource
private UserTunnelService userTunnelService;
@Override
public R getTunnelGroups() {
List<TunnelGroup> groups = tunnelGroupMapper.selectList(new QueryWrapper<TunnelGroup>().orderByAsc("id"));
List<TunnelGroupTunnel> mappings = tunnelGroupTunnelMapper.selectList(new QueryWrapper<TunnelGroupTunnel>());
Map<Long, List<TunnelGroupTunnel>> mappingByGroupId = mappings.stream()
.collect(Collectors.groupingBy(TunnelGroupTunnel::getTunnelGroupId));
Set<Long> tunnelIds = mappings.stream().map(TunnelGroupTunnel::getTunnelId).collect(Collectors.toSet());
Map<Long, String> tunnelNameMap = buildTunnelNameMap(tunnelIds);
List<TunnelGroupDetailDto> result = new ArrayList<>();
for (TunnelGroup group : groups) {
TunnelGroupDetailDto dto = new TunnelGroupDetailDto();
dto.setId(group.getId());
dto.setName(group.getName());
dto.setStatus(group.getStatus());
dto.setCreatedTime(group.getCreatedTime());
dto.setUpdatedTime(group.getUpdatedTime());
List<TunnelGroupTunnel> groupMappings = mappingByGroupId.getOrDefault(group.getId(), Collections.emptyList());
List<Long> ids = groupMappings.stream().map(TunnelGroupTunnel::getTunnelId).collect(Collectors.toList());
List<String> names = ids.stream().map(tunnelNameMap::get).filter(name -> name != null && !name.isBlank()).collect(Collectors.toList());
dto.setTunnelIds(ids);
dto.setTunnelNames(names);
result.add(dto);
}
return R.ok(result);
}
@Override
public R createTunnelGroup(GroupCreateDto dto) {
String name = dto.getName().trim();
int count = tunnelGroupMapper.selectCount(new QueryWrapper<TunnelGroup>().eq("name", name));
if (count > 0) {
return R.err("隧道分组名称已存在");
}
long now = System.currentTimeMillis();
TunnelGroup group = new TunnelGroup();
group.setName(name);
group.setStatus(normalizeStatus(dto.getStatus()));
group.setCreatedTime(now);
group.setUpdatedTime(now);
tunnelGroupMapper.insert(group);
return R.ok();
}
@Override
public R updateTunnelGroup(GroupUpdateDto dto) {
TunnelGroup group = tunnelGroupMapper.selectById(dto.getId());
if (group == null) {
return R.err("隧道分组不存在");
}
String name = dto.getName().trim();
int count = tunnelGroupMapper.selectCount(new QueryWrapper<TunnelGroup>().eq("name", name).ne("id", dto.getId()));
if (count > 0) {
return R.err("隧道分组名称已存在");
}
group.setName(name);
if (dto.getStatus() != null) {
group.setStatus(dto.getStatus());
}
group.setUpdatedTime(System.currentTimeMillis());
tunnelGroupMapper.updateById(group);
return R.ok();
}
@Override
public R deleteTunnelGroup(Long id) {
TunnelGroup group = tunnelGroupMapper.selectById(id);
if (group == null) {
return R.err("隧道分组不存在");
}
List<GroupPermissionGrant> grants = groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>().eq("tunnel_group_id", id));
revokeGrantRecords(grants);
tunnelGroupTunnelMapper.delete(new QueryWrapper<TunnelGroupTunnel>().eq("tunnel_group_id", id));
groupPermissionMapper.delete(new QueryWrapper<GroupPermission>().eq("tunnel_group_id", id));
tunnelGroupMapper.deleteById(id);
return R.ok();
}
@Override
public R assignTunnelsToGroup(TunnelGroupAssignTunnelsDto dto) {
TunnelGroup group = tunnelGroupMapper.selectById(dto.getGroupId());
if (group == null) {
return R.err("隧道分组不存在");
}
Set<Long> tunnelIds = new LinkedHashSet<>(dto.getTunnelIds());
if (!tunnelIds.isEmpty()) {
List<Tunnel> tunnels = tunnelService.list(new QueryWrapper<Tunnel>().in("id", tunnelIds));
if (tunnels.size() != tunnelIds.size()) {
return R.err("隧道列表中存在无效ID");
}
}
tunnelGroupTunnelMapper.delete(new QueryWrapper<TunnelGroupTunnel>().eq("tunnel_group_id", dto.getGroupId()));
if (!tunnelIds.isEmpty()) {
long now = System.currentTimeMillis();
for (Long tunnelId : tunnelIds) {
TunnelGroupTunnel relation = new TunnelGroupTunnel();
relation.setTunnelGroupId(dto.getGroupId());
relation.setTunnelId(tunnelId);
relation.setCreatedTime(now);
tunnelGroupTunnelMapper.insert(relation);
}
}
syncByTunnelGroup(dto.getGroupId());
return R.ok();
}
@Override
public R getUserGroups() {
List<UserGroup> groups = userGroupMapper.selectList(new QueryWrapper<UserGroup>().orderByAsc("id"));
List<UserGroupUser> mappings = userGroupUserMapper.selectList(new QueryWrapper<UserGroupUser>());
Map<Long, List<UserGroupUser>> mappingByGroupId = mappings.stream()
.collect(Collectors.groupingBy(UserGroupUser::getUserGroupId));
Set<Long> userIds = mappings.stream().map(UserGroupUser::getUserId).collect(Collectors.toSet());
Map<Long, String> userNameMap = buildUserNameMap(userIds);
List<UserGroupDetailDto> result = new ArrayList<>();
for (UserGroup group : groups) {
UserGroupDetailDto dto = new UserGroupDetailDto();
dto.setId(group.getId());
dto.setName(group.getName());
dto.setStatus(group.getStatus());
dto.setCreatedTime(group.getCreatedTime());
dto.setUpdatedTime(group.getUpdatedTime());
List<UserGroupUser> groupMappings = mappingByGroupId.getOrDefault(group.getId(), Collections.emptyList());
List<Long> ids = groupMappings.stream().map(UserGroupUser::getUserId).collect(Collectors.toList());
List<String> names = ids.stream().map(userNameMap::get).filter(name -> name != null && !name.isBlank()).collect(Collectors.toList());
dto.setUserIds(ids);
dto.setUserNames(names);
result.add(dto);
}
return R.ok(result);
}
@Override
public R createUserGroup(GroupCreateDto dto) {
String name = dto.getName().trim();
int count = userGroupMapper.selectCount(new QueryWrapper<UserGroup>().eq("name", name));
if (count > 0) {
return R.err("用户分组名称已存在");
}
long now = System.currentTimeMillis();
UserGroup group = new UserGroup();
group.setName(name);
group.setStatus(normalizeStatus(dto.getStatus()));
group.setCreatedTime(now);
group.setUpdatedTime(now);
userGroupMapper.insert(group);
return R.ok();
}
@Override
public R updateUserGroup(GroupUpdateDto dto) {
UserGroup group = userGroupMapper.selectById(dto.getId());
if (group == null) {
return R.err("用户分组不存在");
}
String name = dto.getName().trim();
int count = userGroupMapper.selectCount(new QueryWrapper<UserGroup>().eq("name", name).ne("id", dto.getId()));
if (count > 0) {
return R.err("用户分组名称已存在");
}
group.setName(name);
if (dto.getStatus() != null) {
group.setStatus(dto.getStatus());
}
group.setUpdatedTime(System.currentTimeMillis());
userGroupMapper.updateById(group);
return R.ok();
}
@Override
public R deleteUserGroup(Long id) {
UserGroup group = userGroupMapper.selectById(id);
if (group == null) {
return R.err("用户分组不存在");
}
List<GroupPermissionGrant> grants = groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>().eq("user_group_id", id));
revokeGrantRecords(grants);
userGroupUserMapper.delete(new QueryWrapper<UserGroupUser>().eq("user_group_id", id));
groupPermissionMapper.delete(new QueryWrapper<GroupPermission>().eq("user_group_id", id));
userGroupMapper.deleteById(id);
return R.ok();
}
@Override
public R assignUsersToGroup(UserGroupAssignUsersDto dto) {
UserGroup group = userGroupMapper.selectById(dto.getGroupId());
if (group == null) {
return R.err("用户分组不存在");
}
Set<Long> userIds = new LinkedHashSet<>(dto.getUserIds());
if (!userIds.isEmpty()) {
List<User> users = userService.list(new QueryWrapper<User>().in("id", userIds).ne("role_id", 0));
if (users.size() != userIds.size()) {
return R.err("用户列表中存在无效ID");
}
}
userGroupUserMapper.delete(new QueryWrapper<UserGroupUser>().eq("user_group_id", dto.getGroupId()));
if (!userIds.isEmpty()) {
long now = System.currentTimeMillis();
for (Long userId : userIds) {
UserGroupUser relation = new UserGroupUser();
relation.setUserGroupId(dto.getGroupId());
relation.setUserId(userId);
relation.setCreatedTime(now);
userGroupUserMapper.insert(relation);
}
}
syncByUserGroup(dto.getGroupId());
return R.ok();
}
@Override
public R getGroupPermissions() {
List<GroupPermission> permissions = groupPermissionMapper.selectList(new QueryWrapper<GroupPermission>().orderByDesc("id"));
if (permissions.isEmpty()) {
return R.ok(new ArrayList<GroupPermissionDetailDto>());
}
Set<Long> userGroupIds = permissions.stream().map(GroupPermission::getUserGroupId).collect(Collectors.toSet());
Set<Long> tunnelGroupIds = permissions.stream().map(GroupPermission::getTunnelGroupId).collect(Collectors.toSet());
Map<Long, String> userGroupNameMap = userGroupMapper.selectList(new QueryWrapper<UserGroup>().in("id", userGroupIds)).stream()
.collect(Collectors.toMap(UserGroup::getId, UserGroup::getName));
Map<Long, String> tunnelGroupNameMap = tunnelGroupMapper.selectList(new QueryWrapper<TunnelGroup>().in("id", tunnelGroupIds)).stream()
.collect(Collectors.toMap(TunnelGroup::getId, TunnelGroup::getName));
List<GroupPermissionDetailDto> result = new ArrayList<>();
for (GroupPermission permission : permissions) {
GroupPermissionDetailDto dto = new GroupPermissionDetailDto();
dto.setId(permission.getId());
dto.setUserGroupId(permission.getUserGroupId());
dto.setTunnelGroupId(permission.getTunnelGroupId());
dto.setCreatedTime(permission.getCreatedTime());
dto.setUserGroupName(userGroupNameMap.get(permission.getUserGroupId()));
dto.setTunnelGroupName(tunnelGroupNameMap.get(permission.getTunnelGroupId()));
result.add(dto);
}
return R.ok(result);
}
@Override
public R assignGroupPermission(GroupPermissionAssignDto dto) {
UserGroup userGroup = userGroupMapper.selectById(dto.getUserGroupId());
if (userGroup == null) {
return R.err("用户分组不存在");
}
TunnelGroup tunnelGroup = tunnelGroupMapper.selectById(dto.getTunnelGroupId());
if (tunnelGroup == null) {
return R.err("隧道分组不存在");
}
int existing = groupPermissionMapper.selectCount(new QueryWrapper<GroupPermission>()
.eq("user_group_id", dto.getUserGroupId())
.eq("tunnel_group_id", dto.getTunnelGroupId()));
if (existing == 0) {
GroupPermission permission = new GroupPermission();
permission.setUserGroupId(dto.getUserGroupId());
permission.setTunnelGroupId(dto.getTunnelGroupId());
permission.setCreatedTime(System.currentTimeMillis());
groupPermissionMapper.insert(permission);
}
reconcilePermission(dto.getUserGroupId(), dto.getTunnelGroupId());
return existing > 0 ? R.ok("权限已存在,已完成同步") : R.ok();
}
@Override
public R removeGroupPermission(Long id) {
GroupPermission permission = groupPermissionMapper.selectById(id);
if (permission == null) {
return R.err("权限记录不存在");
}
revokeByPermissionPair(permission.getUserGroupId(), permission.getTunnelGroupId());
groupPermissionMapper.deleteById(id);
return R.ok();
}
private void syncByUserGroup(Long userGroupId) {
List<GroupPermission> permissions = groupPermissionMapper.selectList(new QueryWrapper<GroupPermission>().eq("user_group_id", userGroupId));
for (GroupPermission permission : permissions) {
reconcilePermission(permission.getUserGroupId(), permission.getTunnelGroupId());
}
}
private void syncByTunnelGroup(Long tunnelGroupId) {
List<GroupPermission> permissions = groupPermissionMapper.selectList(new QueryWrapper<GroupPermission>().eq("tunnel_group_id", tunnelGroupId));
for (GroupPermission permission : permissions) {
reconcilePermission(permission.getUserGroupId(), permission.getTunnelGroupId());
}
}
private void reconcilePermission(Long userGroupId, Long tunnelGroupId) {
Set<Long> userIds = userGroupUserMapper.selectList(new QueryWrapper<UserGroupUser>().eq("user_group_id", userGroupId)).stream()
.map(UserGroupUser::getUserId)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> tunnelIds = tunnelGroupTunnelMapper.selectList(new QueryWrapper<TunnelGroupTunnel>().eq("tunnel_group_id", tunnelGroupId)).stream()
.map(TunnelGroupTunnel::getTunnelId)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<String> desiredKeys = new HashSet<>();
for (Long userId : userIds) {
for (Long tunnelId : tunnelIds) {
desiredKeys.add(permissionKey(userId, tunnelId));
}
}
List<GroupPermissionGrant> currentGrants = groupPermissionGrantMapper.selectList(
new QueryWrapper<GroupPermissionGrant>()
.eq("user_group_id", userGroupId)
.eq("tunnel_group_id", tunnelGroupId)
);
Set<Long> grantUserTunnelIds = currentGrants.stream().map(GroupPermissionGrant::getUserTunnelId).collect(Collectors.toSet());
Map<Long, UserTunnel> grantUserTunnelMap = new HashMap<>();
if (!grantUserTunnelIds.isEmpty()) {
List<UserTunnel> userTunnels = userTunnelService.list(new QueryWrapper<UserTunnel>().in("id", grantUserTunnelIds));
grantUserTunnelMap = userTunnels.stream().collect(Collectors.toMap(ut -> ut.getId().longValue(), Function.identity()));
}
Map<String, UserTunnel> pairUserTunnelMap = new HashMap<>();
if (!userIds.isEmpty() && !tunnelIds.isEmpty()) {
List<UserTunnel> pairUserTunnels = userTunnelService.list(new QueryWrapper<UserTunnel>()
.in("user_id", userIds)
.in("tunnel_id", tunnelIds));
for (UserTunnel userTunnel : pairUserTunnels) {
pairUserTunnelMap.putIfAbsent(permissionKey(userTunnel.getUserId().longValue(), userTunnel.getTunnelId().longValue()), userTunnel);
}
}
Set<Long> pairUserTunnelIds = pairUserTunnelMap.values().stream()
.map(ut -> ut.getId().longValue())
.collect(Collectors.toSet());
Map<Long, Long> totalGrantCountMap = buildGrantCountMap(pairUserTunnelIds);
Set<Long> groupManagedUserTunnelIds = buildGroupManagedUserTunnelIds(pairUserTunnelIds);
Set<Long> currentGrantUserTunnelIds = currentGrants.stream().map(GroupPermissionGrant::getUserTunnelId).collect(Collectors.toSet());
if (!desiredKeys.isEmpty()) {
Map<Long, User> userMap = userService.list(new QueryWrapper<User>().in("id", userIds)).stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
long now = System.currentTimeMillis();
for (Long userId : userIds) {
User user = userMap.get(userId);
if (user == null) {
continue;
}
for (Long tunnelId : tunnelIds) {
String pairKey = permissionKey(userId, tunnelId);
UserTunnel userTunnel = pairUserTunnelMap.get(pairKey);
if (userTunnel == null) {
userTunnel = createGroupManagedUserTunnel(userId, tunnelId, user);
pairUserTunnelMap.put(pairKey, userTunnel);
createGrant(userGroupId, tunnelGroupId, userTunnel.getId().longValue(), true, now);
currentGrantUserTunnelIds.add(userTunnel.getId().longValue());
totalGrantCountMap.put(userTunnel.getId().longValue(), 1L);
groupManagedUserTunnelIds.add(userTunnel.getId().longValue());
continue;
}
Long userTunnelId = userTunnel.getId().longValue();
if (currentGrantUserTunnelIds.contains(userTunnelId)) {
continue;
}
long existingGrantCount = totalGrantCountMap.getOrDefault(userTunnelId, 0L);
boolean createdByGroup = groupManagedUserTunnelIds.contains(userTunnelId);
createGrant(userGroupId, tunnelGroupId, userTunnelId, createdByGroup, now);
currentGrantUserTunnelIds.add(userTunnelId);
totalGrantCountMap.put(userTunnelId, existingGrantCount + 1L);
}
}
}
List<GroupPermissionGrant> staleGrants = new ArrayList<>();
for (GroupPermissionGrant grant : currentGrants) {
UserTunnel userTunnel = grantUserTunnelMap.get(grant.getUserTunnelId());
boolean keep = false;
if (userTunnel != null) {
String key = permissionKey(userTunnel.getUserId().longValue(), userTunnel.getTunnelId().longValue());
keep = desiredKeys.contains(key);
}
if (!keep) {
staleGrants.add(grant);
}
}
revokeGrantRecords(staleGrants);
}
private UserTunnel createGroupManagedUserTunnel(Long userId, Long tunnelId, User user) {
UserTunnel userTunnel = new UserTunnel();
userTunnel.setUserId(userId.intValue());
userTunnel.setTunnelId(tunnelId.intValue());
userTunnel.setStatus(1);
userTunnel.setInFlow(0L);
userTunnel.setOutFlow(0L);
userTunnel.setFlow(user.getFlow());
userTunnel.setNum(user.getNum());
userTunnel.setFlowResetTime(user.getFlowResetTime());
userTunnel.setExpTime(user.getExpTime());
userTunnelService.save(userTunnel);
return userTunnel;
}
private void createGrant(Long userGroupId, Long tunnelGroupId, Long userTunnelId, boolean createdByGroup, long createdTime) {
int exists = groupPermissionGrantMapper.selectCount(new QueryWrapper<GroupPermissionGrant>()
.eq("user_group_id", userGroupId)
.eq("tunnel_group_id", tunnelGroupId)
.eq("user_tunnel_id", userTunnelId));
if (exists > 0) {
return;
}
GroupPermissionGrant grant = new GroupPermissionGrant();
grant.setUserGroupId(userGroupId);
grant.setTunnelGroupId(tunnelGroupId);
grant.setUserTunnelId(userTunnelId);
grant.setCreatedByGroup(createdByGroup ? 1 : 0);
grant.setCreatedTime(createdTime);
groupPermissionGrantMapper.insert(grant);
}
private void revokeByPermissionPair(Long userGroupId, Long tunnelGroupId) {
List<GroupPermissionGrant> grants = groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>()
.eq("user_group_id", userGroupId)
.eq("tunnel_group_id", tunnelGroupId));
revokeGrantRecords(grants);
}
private void revokeGrantRecords(List<GroupPermissionGrant> grants) {
if (grants == null || grants.isEmpty()) {
return;
}
Set<Long> candidateUserTunnelIds = new HashSet<>();
Set<Long> groupManagedCandidates = new HashSet<>();
for (GroupPermissionGrant grant : grants) {
candidateUserTunnelIds.add(grant.getUserTunnelId());
if (grant.getCreatedByGroup() != null && grant.getCreatedByGroup() == 1) {
groupManagedCandidates.add(grant.getUserTunnelId());
}
groupPermissionGrantMapper.deleteById(grant.getId());
}
Set<Long> stillGrantedUserTunnelIds = groupPermissionGrantMapper.selectList(
new QueryWrapper<GroupPermissionGrant>().in("user_tunnel_id", candidateUserTunnelIds)
).stream()
.map(GroupPermissionGrant::getUserTunnelId)
.collect(Collectors.toSet());
for (Long userTunnelId : candidateUserTunnelIds) {
if (!stillGrantedUserTunnelIds.contains(userTunnelId) && groupManagedCandidates.contains(userTunnelId)) {
userTunnelService.removeUserTunnel(userTunnelId.intValue());
}
}
}
private Set<Long> buildGroupManagedUserTunnelIds(Set<Long> userTunnelIds) {
if (userTunnelIds.isEmpty()) {
return Collections.emptySet();
}
return groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>()
.in("user_tunnel_id", userTunnelIds)
.eq("created_by_group", 1))
.stream()
.map(GroupPermissionGrant::getUserTunnelId)
.collect(Collectors.toSet());
}
private Map<Long, Long> buildGrantCountMap(Set<Long> userTunnelIds) {
if (userTunnelIds.isEmpty()) {
return new HashMap<>();
}
List<GroupPermissionGrant> grants = groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>().in("user_tunnel_id", userTunnelIds));
Map<Long, Long> countMap = new HashMap<>();
for (GroupPermissionGrant grant : grants) {
countMap.merge(grant.getUserTunnelId(), 1L, Long::sum);
}
return countMap;
}
private Map<Long, String> buildTunnelNameMap(Set<Long> tunnelIds) {
if (tunnelIds.isEmpty()) {
return Collections.emptyMap();
}
return tunnelService.list(new QueryWrapper<Tunnel>().in("id", tunnelIds)).stream()
.collect(Collectors.toMap(Tunnel::getId, Tunnel::getName));
}
private Map<Long, String> buildUserNameMap(Set<Long> userIds) {
if (userIds.isEmpty()) {
return Collections.emptyMap();
}
return userService.list(new QueryWrapper<User>().in("id", userIds)).stream()
.collect(Collectors.toMap(User::getId, User::getUser));
}
private String permissionKey(Long userId, Long tunnelId) {
return userId + "_" + tunnelId;
}
private int normalizeStatus(Integer status) {
return status == null ? 1 : status;
}
}
@@ -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);
}
}
@@ -8,6 +8,8 @@ import com.admin.common.utils.JwtUtil;
import com.admin.common.utils.WebSocketServer;
import com.admin.entity.*;
import com.admin.mapper.TunnelMapper;
import com.admin.mapper.GroupPermissionGrantMapper;
import com.admin.mapper.TunnelGroupTunnelMapper;
import com.admin.mapper.UserTunnelMapper;
import com.admin.service.*;
import com.alibaba.fastjson.JSONArray;
@@ -52,6 +54,12 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
@Resource
ForwardPortService forwardPortService;
@Resource
TunnelGroupTunnelMapper tunnelGroupTunnelMapper;
@Resource
GroupPermissionGrantMapper groupPermissionGrantMapper;
@Override
public R createTunnel(TunnelDto tunnelDto) {
@@ -578,8 +586,16 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (Forward forward : forwardList) {
forwardService.deleteForward(forward.getId());
}
List<UserTunnel> userTunnels = userTunnelService.list(new QueryWrapper<UserTunnel>().eq("tunnel_id", id));
if (!userTunnels.isEmpty()) {
List<Integer> userTunnelIds = userTunnels.stream().map(UserTunnel::getId).toList();
groupPermissionGrantMapper.delete(new QueryWrapper<com.admin.entity.GroupPermissionGrant>().in("user_tunnel_id", userTunnelIds));
}
forwardService.remove(new QueryWrapper<Forward>().eq("tunnel_id", id));
userTunnelService.remove(new QueryWrapper<UserTunnel>().eq("tunnel_id", id));
tunnelGroupTunnelMapper.delete(new QueryWrapper<TunnelGroupTunnel>().eq("tunnel_id", id));
this.removeById(id);
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", id));
@@ -1342,5 +1358,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);
}
}
@@ -11,6 +11,8 @@ import com.admin.common.utils.JwtUtil;
import com.admin.common.utils.Md5Util;
import com.admin.entity.*;
import com.admin.mapper.UserMapper;
import com.admin.mapper.GroupPermissionGrantMapper;
import com.admin.mapper.UserGroupUserMapper;
import com.admin.service.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -56,6 +58,12 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
@Resource
StatisticsFlowService statisticsFlowService;
@Resource
GroupPermissionGrantMapper groupPermissionGrantMapper;
@Resource
UserGroupUserMapper userGroupUserMapper;
@Resource
@Lazy
ForwardPortService forwardPortService;
@@ -140,8 +148,16 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
for (Forward forward : forwardList) {
forwardService.deleteForward(forward.getId());
}
List<UserTunnel> userTunnels = userTunnelService.list(new QueryWrapper<UserTunnel>().eq("user_id", id));
if (!userTunnels.isEmpty()) {
List<Integer> userTunnelIds = userTunnels.stream().map(UserTunnel::getId).toList();
groupPermissionGrantMapper.delete(new QueryWrapper<com.admin.entity.GroupPermissionGrant>().in("user_tunnel_id", userTunnelIds));
}
forwardService.remove(new QueryWrapper<Forward>().eq("user_id", id));
userTunnelService.remove(new QueryWrapper<UserTunnel>().eq("user_id", id));
userGroupUserMapper.delete(new QueryWrapper<UserGroupUser>().eq("user_id", id));
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
this.removeById(id);
return R.ok();
@@ -2,8 +2,10 @@ package com.admin.service.impl;
import com.admin.common.dto.*;
import com.admin.common.lang.R;
import com.admin.entity.GroupPermissionGrant;
import com.admin.entity.User;
import com.admin.entity.UserTunnel;
import com.admin.mapper.GroupPermissionGrantMapper;
import com.admin.mapper.UserTunnelMapper;
import com.admin.service.UserService;
import com.admin.service.UserTunnelService;
@@ -34,6 +36,9 @@ public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTun
@Lazy
private UserService userService;
@Resource
private GroupPermissionGrantMapper groupPermissionGrantMapper;
@Override
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
int count = this.count(new QueryWrapper<UserTunnel>()
@@ -123,6 +128,13 @@ public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTun
UserTunnel userTunnel = this.getById(id);
if (userTunnel == null) return R.err("未找到对应的用户隧道权限记录");
int grantCount = groupPermissionGrantMapper.selectCount(
new QueryWrapper<GroupPermissionGrant>().eq("user_tunnel_id", id)
);
if (grantCount > 0) {
return R.err("该隧道权限由分组授权,请先调整分组权限或分组成员");
}
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>()
.eq("user_id", userTunnel.getUserId())
.eq("tunnel_id", userTunnel.getTunnelId()));
@@ -121,6 +121,59 @@ CREATE TABLE IF NOT EXISTS user_tunnel (
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tunnel_group_id INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission_grant (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
user_tunnel_id INTEGER NOT NULL,
created_by_group INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
CREATE TABLE IF NOT EXISTS vite_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(200) NOT NULL UNIQUE,
+7 -7
View File
@@ -45,7 +45,7 @@
"@react-aria/visually-hidden": "3.8.25",
"@react-types/shared": "3.30.0",
"@tailwindcss/postcss": "4.1.11",
"@tailwindcss/vite": "4.1.11",
"@tailwindcss/vite": "^4.1.18",
"@types/react-beautiful-dnd": "^13.1.8",
"axios": "^1.11.0",
"clsx": "2.1.1",
@@ -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",
"@vitejs/plugin-react": "^5.1.3",
"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-tsconfig-paths": "4.3.2"
"vite": "npm:rolldown-vite@^7.3.1",
"vite-tsconfig-paths": "^6.0.5"
}
}
+10 -3
View File
@@ -8,6 +8,7 @@ import ForwardPage from "@/pages/forward";
import TunnelPage from "@/pages/tunnel";
import NodePage from "@/pages/node";
import UserPage from "@/pages/user";
import GroupPage from "@/pages/group";
import ProfilePage from "@/pages/profile";
import LimitPage from "@/pages/limit";
import ConfigPage from "@/pages/config";
@@ -148,9 +149,7 @@ function App() {
if (cachedAppName && cachedAppName !== document.title) {
document.title = cachedAppName;
}
} catch (error) {
console.warn("检查标题更新失败:", error);
}
} catch {}
};
// 延迟检查,避免阻塞初始渲染
@@ -210,6 +209,14 @@ function App() {
}
path="/user"
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<GroupPage />
</ProtectedRoute>
}
path="/group"
/>
<Route
element={
<ProtectedRoute>
+60
View File
@@ -127,3 +127,63 @@ 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);
// 分组与权限分配接口
export const getTunnelGroupList = () => Network.post("/group/tunnel/list");
export const createTunnelGroup = (data: { name: string; status?: number }) =>
Network.post("/group/tunnel/create", data);
export const updateTunnelGroup = (data: {
id: number;
name: string;
status?: number;
}) => Network.post("/group/tunnel/update", data);
export const deleteTunnelGroup = (id: number) =>
Network.post("/group/tunnel/delete", { id });
export const assignTunnelsToGroup = (data: {
groupId: number;
tunnelIds: number[];
}) => Network.post("/group/tunnel/assign", data);
export const getUserGroupList = () => Network.post("/group/user/list");
export const createUserGroup = (data: { name: string; status?: number }) =>
Network.post("/group/user/create", data);
export const updateUserGroup = (data: {
id: number;
name: string;
status?: number;
}) => Network.post("/group/user/update", data);
export const deleteUserGroup = (id: number) =>
Network.post("/group/user/delete", { id });
export const assignUsersToGroup = (data: {
groupId: number;
userIds: number[];
}) => Network.post("/group/user/assign", data);
export const getGroupPermissionList = () =>
Network.post("/group/permission/list");
export const assignGroupPermission = (data: {
userGroupId: number;
tunnelGroupId: number;
}) => Network.post("/group/permission/assign", data);
export const removeGroupPermission = (id: number) =>
Network.post("/group/permission/remove", { id });
-4
View File
@@ -98,8 +98,6 @@ const Network = {
resolve(response.data);
})
.catch(function (error: any) {
console.error("GET请求错误:", error);
// 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) {
handleTokenExpired();
@@ -146,8 +144,6 @@ const Network = {
resolve(response.data);
})
.catch(function (error: any) {
console.error("POST请求错误:", error);
// 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) {
handleTokenExpired();
+2 -6
View File
@@ -33,9 +33,7 @@ export const Navbar = () => {
// 同步更新siteConfig
siteConfig.name = cachedAppName;
}
} catch (error) {
console.warn("检查配置更新失败:", error);
}
} catch {}
};
// 延迟执行,避免阻塞初始渲染
@@ -50,9 +48,7 @@ export const Navbar = () => {
setAppName(cachedAppName);
siteConfig.name = cachedAppName;
}
} catch (error) {
console.warn("更新配置失败:", error);
}
} catch {}
};
window.addEventListener("configUpdated", handleConfigUpdate);
+1 -1
View File
@@ -126,7 +126,7 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
return configs;
}
} catch (error) {
} catch {
// API失败时返回缓存的数据
if (hasCachedData) {
return cachedConfigs;
+21 -10
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" />
@@ -134,9 +134,19 @@ export default function AdminLayout({
),
adminOnly: true,
},
{
path: "/group",
label: "分组",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a3 3 0 100 6 3 3 0 000-6zM4 9a3 3 0 100 6 3 3 0 000-6zm12 0a3 3 0 100 6 3 3 0 000-6M4 16a2 2 0 00-2 2h4a2 2 0 00-2-2zm12 0a2 2 0 00-2 2h4a2 2 0 00-2-2zm-6 0a2 2 0 00-2 2h4a2 2 0 00-2-2z" />
</svg>
),
adminOnly: true,
},
{
path: "/config",
label: "网站配置",
label: "设置",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
@@ -260,9 +270,8 @@ export default function AdminLayout({
} else {
toast.error(response.msg || "密码修改失败");
}
} catch (error) {
} catch {
toast.error("修改密码时发生错误");
console.error("修改密码错误:", error);
} finally {
setPasswordLoading(false);
}
@@ -289,8 +298,10 @@ export default function AdminLayout({
>
{/* 移动端遮罩层 */}
{isMobile && mobileMenuVisible && (
<div
<button
aria-label="关闭菜单"
className="fixed inset-0 backdrop-blur-sm bg-white/50 dark:bg-black/30 z-40"
type="button"
onClick={hideMobileMenu}
/>
)}
+1 -1
View File
@@ -17,7 +17,7 @@ export default function H5SimpleLayout({
React.useEffect(() => {
try {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
} catch (e) {
} catch {
window.scrollTo(0, 0);
}
document.body.scrollTop = 0;
+1 -1
View File
@@ -108,7 +108,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
useEffect(() => {
try {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
} catch (e) {
} catch {
window.scrollTo(0, 0);
}
document.body.scrollTop = 0;
+1 -2
View File
@@ -89,8 +89,7 @@ export default function ChangePasswordPage() {
} else {
toast.error(response.msg || "账号密码修改失败");
}
} catch (error) {
console.error("修改账号密码错误:", error);
} catch {
toast.error("修改账号密码时发生错误");
} finally {
setLoading(false);
+3 -3
View File
@@ -121,7 +121,7 @@ const getInitialConfigs = (): Record<string, string> => {
initialConfigs[key] = cachedValue;
}
});
} catch (error) {}
} catch {}
return initialConfigs;
};
@@ -172,7 +172,7 @@ export default function ConfigPage() {
setHasChanges(false);
} else {
}
} catch (error) {
} catch {
// 只有在没有缓存数据时才显示错误
if (!hasInitialData) {
toast.error("加载配置出错,请重试");
@@ -250,7 +250,7 @@ export default function ConfigPage() {
} else {
toast.error("保存配置失败: " + response.msg);
}
} catch (error) {
} catch {
toast.error("保存配置出错,请重试");
} finally {
setSaving(false);
+16 -13
View File
@@ -207,8 +207,7 @@ export default function DashboardPage() {
} else {
toast.error(res.msg || "获取套餐信息失败");
}
} catch (error) {
console.error("获取套餐信息失败:", error);
} catch {
toast.error("获取套餐信息失败");
} finally {
setLoading(false);
@@ -648,7 +647,7 @@ export default function DashboardPage() {
try {
await navigator.clipboard.writeText(text);
toast.success(`已复制`);
} catch (error) {
} catch {
toast.error("复制失败");
}
};
@@ -661,7 +660,7 @@ export default function DashboardPage() {
),
);
await copyToClipboard(addressItem.address);
} catch (error) {
} catch {
toast.error("复制失败");
} finally {
setAddressList((prev) =>
@@ -1189,14 +1188,15 @@ export default function DashboardPage() {
{forward.name}
</h4>
<div className="space-y-1">
<code
<button
className={`block px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 rounded font-mono text-xs truncate ${hasMultipleIps(forward.inIp) ? "cursor-pointer hover:bg-green-200 dark:hover:bg-green-500/30" : ""}`}
disabled={!hasMultipleIps(forward.inIp)}
title={formatInAddress(
forward.inIp,
forward.inPort,
)}
type="button"
onClick={() =>
hasMultipleIps(forward.inIp) &&
showAddressModal(
forward.inIp,
forward.inPort,
@@ -1205,17 +1205,20 @@ export default function DashboardPage() {
}
>
{formatInAddress(forward.inIp, forward.inPort)}
</code>
</button>
<div className="text-center text-default-400 text-xs">
↓
</div>
<code
<button
className={`block px-2 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded font-mono text-xs truncate ${hasMultipleRemoteAddresses(forward.remoteAddr) ? "cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-500/30" : ""}`}
title={formatRemoteAddress(forward.remoteAddr)}
onClick={() =>
hasMultipleRemoteAddresses(
disabled={
!hasMultipleRemoteAddresses(
forward.remoteAddr,
) &&
)
}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
showRemoteAddressModal(
forward.remoteAddr,
"出口地址",
@@ -1223,7 +1226,7 @@ export default function DashboardPage() {
}
>
{formatRemoteAddress(forward.remoteAddr)}
</code>
</button>
</div>
</div>
File diff suppressed because it is too large Load Diff
+858
View File
@@ -0,0 +1,858 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure,
} from "@heroui/modal";
import { Select, SelectItem } from "@heroui/select";
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@heroui/table";
import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner";
import toast from "react-hot-toast";
import {
assignGroupPermission,
assignTunnelsToGroup,
assignUsersToGroup,
createTunnelGroup,
createUserGroup,
deleteTunnelGroup,
deleteUserGroup,
getAllUsers,
getGroupPermissionList,
getTunnelGroupList,
getTunnelList,
getUserGroupList,
removeGroupPermission,
updateTunnelGroup,
updateUserGroup,
} from "@/api";
interface TunnelItem {
id: number;
name: string;
}
interface UserItem {
id: number;
user: string;
}
interface TunnelGroup {
id: number;
name: string;
status: number;
tunnelIds: number[];
tunnelNames: string[];
createdTime: number;
}
interface UserGroup {
id: number;
name: string;
status: number;
userIds: number[];
userNames: string[];
createdTime: number;
}
interface GroupPermission {
id: number;
userGroupId: number;
userGroupName: string;
tunnelGroupId: number;
tunnelGroupName: string;
createdTime: number;
}
const formatDate = (timestamp?: number): string => {
if (!timestamp) {
return "-";
}
return new Date(timestamp).toLocaleString();
};
const isAdminUser = () => {
let adminFlag = localStorage.getItem("admin") === "true";
if (localStorage.getItem("admin") === null) {
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
adminFlag = roleId === 0;
localStorage.setItem("admin", adminFlag.toString());
}
return adminFlag;
};
export default function GroupPage() {
const [loading, setLoading] = useState(true);
const [isAdmin] = useState(isAdminUser());
const [tunnelGroups, setTunnelGroups] = useState<TunnelGroup[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const [permissions, setPermissions] = useState<GroupPermission[]>([]);
const [tunnels, setTunnels] = useState<TunnelItem[]>([]);
const [users, setUsers] = useState<UserItem[]>([]);
const [selectedUserGroupId, setSelectedUserGroupId] = useState<number | null>(
null,
);
const [selectedTunnelGroupId, setSelectedTunnelGroupId] = useState<
number | null
>(null);
const [savingPermission, setSavingPermission] = useState(false);
const {
isOpen: tunnelGroupModalOpen,
onOpen: onTunnelGroupModalOpen,
onClose: onTunnelGroupModalClose,
onOpenChange: onTunnelGroupModalChange,
} = useDisclosure();
const {
isOpen: userGroupModalOpen,
onOpen: onUserGroupModalOpen,
onClose: onUserGroupModalClose,
onOpenChange: onUserGroupModalChange,
} = useDisclosure();
const {
isOpen: tunnelAssignModalOpen,
onOpen: onTunnelAssignModalOpen,
onClose: onTunnelAssignModalClose,
onOpenChange: onTunnelAssignModalChange,
} = useDisclosure();
const {
isOpen: userAssignModalOpen,
onOpen: onUserAssignModalOpen,
onClose: onUserAssignModalClose,
onOpenChange: onUserAssignModalChange,
} = useDisclosure();
const [editingTunnelGroup, setEditingTunnelGroup] =
useState<TunnelGroup | null>(null);
const [editingUserGroup, setEditingUserGroup] = useState<UserGroup | null>(
null,
);
const [groupName, setGroupName] = useState("");
const [groupStatus, setGroupStatus] = useState("1");
const [savingGroup, setSavingGroup] = useState(false);
const [assignTunnelGroup, setAssignTunnelGroup] =
useState<TunnelGroup | null>(null);
const [assignUserGroup, setAssignUserGroup] = useState<UserGroup | null>(
null,
);
const [selectedTunnelKeys, setSelectedTunnelKeys] = useState<Set<string>>(
new Set(),
);
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
new Set(),
);
const [savingAssign, setSavingAssign] = useState(false);
const tunnelNameMap = useMemo(() => {
const map = new Map<number, string>();
tunnels.forEach((item) => {
map.set(item.id, item.name);
});
return map;
}, [tunnels]);
const userNameMap = useMemo(() => {
const map = new Map<number, string>();
users.forEach((item) => {
map.set(item.id, item.user);
});
return map;
}, [users]);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [tunnelGroupRes, userGroupRes, permissionRes, tunnelRes, userRes] =
await Promise.all([
getTunnelGroupList(),
getUserGroupList(),
getGroupPermissionList(),
getTunnelList(),
getAllUsers(),
]);
if (tunnelGroupRes.code === 0) {
setTunnelGroups(tunnelGroupRes.data || []);
}
if (userGroupRes.code === 0) {
setUserGroups(userGroupRes.data || []);
}
if (permissionRes.code === 0) {
setPermissions(permissionRes.data || []);
}
if (tunnelRes.code === 0) {
setTunnels(tunnelRes.data || []);
}
if (userRes.code === 0) {
setUsers(userRes.data || []);
}
if (
tunnelGroupRes.code !== 0 ||
userGroupRes.code !== 0 ||
permissionRes.code !== 0
) {
toast.error("部分分组数据加载失败");
}
} catch {
toast.error("分组数据加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const openCreateTunnelGroup = () => {
setEditingTunnelGroup(null);
setGroupName("");
setGroupStatus("1");
onTunnelGroupModalOpen();
};
const openEditTunnelGroup = (group: TunnelGroup) => {
setEditingTunnelGroup(group);
setGroupName(group.name);
setGroupStatus(String(group.status));
onTunnelGroupModalOpen();
};
const openCreateUserGroup = () => {
setEditingUserGroup(null);
setGroupName("");
setGroupStatus("1");
onUserGroupModalOpen();
};
const openEditUserGroup = (group: UserGroup) => {
setEditingUserGroup(group);
setGroupName(group.name);
setGroupStatus(String(group.status));
onUserGroupModalOpen();
};
const saveTunnelGroup = async () => {
if (!groupName.trim()) {
toast.error("请输入分组名称");
return;
}
setSavingGroup(true);
try {
const payload = { name: groupName.trim(), status: Number(groupStatus) };
const res = editingTunnelGroup
? await updateTunnelGroup({ id: editingTunnelGroup.id, ...payload })
: await createTunnelGroup(payload);
if (res.code === 0) {
toast.success(editingTunnelGroup ? "更新成功" : "创建成功");
onTunnelGroupModalClose();
loadData();
} else {
toast.error(res.msg || "保存失败");
}
} catch {
toast.error("保存失败");
} finally {
setSavingGroup(false);
}
};
const saveUserGroup = async () => {
if (!groupName.trim()) {
toast.error("请输入分组名称");
return;
}
setSavingGroup(true);
try {
const payload = { name: groupName.trim(), status: Number(groupStatus) };
const res = editingUserGroup
? await updateUserGroup({ id: editingUserGroup.id, ...payload })
: await createUserGroup(payload);
if (res.code === 0) {
toast.success(editingUserGroup ? "更新成功" : "创建成功");
onUserGroupModalClose();
loadData();
} else {
toast.error(res.msg || "保存失败");
}
} catch {
toast.error("保存失败");
} finally {
setSavingGroup(false);
}
};
const handleDeleteTunnelGroup = async (id: number) => {
try {
const res = await deleteTunnelGroup(id);
if (res.code === 0) {
toast.success("删除成功");
loadData();
} else {
toast.error(res.msg || "删除失败");
}
} catch {
toast.error("删除失败");
}
};
const handleDeleteUserGroup = async (id: number) => {
try {
const res = await deleteUserGroup(id);
if (res.code === 0) {
toast.success("删除成功");
loadData();
} else {
toast.error(res.msg || "删除失败");
}
} catch {
toast.error("删除失败");
}
};
const openAssignTunnels = (group: TunnelGroup) => {
setAssignTunnelGroup(group);
setSelectedTunnelKeys(new Set(group.tunnelIds.map((id) => String(id))));
onTunnelAssignModalOpen();
};
const openAssignUsers = (group: UserGroup) => {
setAssignUserGroup(group);
setSelectedUserKeys(new Set(group.userIds.map((id) => String(id))));
onUserAssignModalOpen();
};
const saveAssignTunnels = async () => {
if (!assignTunnelGroup) return;
setSavingAssign(true);
try {
const tunnelIds = Array.from(selectedTunnelKeys).map((id) => Number(id));
const res = await assignTunnelsToGroup({
groupId: assignTunnelGroup.id,
tunnelIds,
});
if (res.code === 0) {
toast.success("分配成功");
onTunnelAssignModalClose();
loadData();
} else {
toast.error(res.msg || "分配失败");
}
} catch {
toast.error("分配失败");
} finally {
setSavingAssign(false);
}
};
const saveAssignUsers = async () => {
if (!assignUserGroup) return;
setSavingAssign(true);
try {
const userIds = Array.from(selectedUserKeys).map((id) => Number(id));
const res = await assignUsersToGroup({
groupId: assignUserGroup.id,
userIds,
});
if (res.code === 0) {
toast.success("分配成功");
onUserAssignModalClose();
loadData();
} else {
toast.error(res.msg || "分配失败");
}
} catch {
toast.error("分配失败");
} finally {
setSavingAssign(false);
}
};
const handleAssignPermission = async () => {
if (!selectedUserGroupId || !selectedTunnelGroupId) {
toast.error("请选择用户分组和隧道分组");
return;
}
setSavingPermission(true);
try {
const res = await assignGroupPermission({
userGroupId: selectedUserGroupId,
tunnelGroupId: selectedTunnelGroupId,
});
if (res.code === 0) {
toast.success(res.msg || "权限分配成功");
loadData();
} else {
toast.error(res.msg || "权限分配失败");
}
} catch {
toast.error("权限分配失败");
} finally {
setSavingPermission(false);
}
};
const handleRemovePermission = async (id: number) => {
try {
const res = await removeGroupPermission(id);
if (res.code === 0) {
toast.success("权限回收成功");
loadData();
} else {
toast.error(res.msg || "权限回收失败");
}
} catch {
toast.error("权限回收失败");
}
};
if (!isAdmin) {
return (
<div className="px-3 lg:px-6 py-8">
<Card>
<CardBody>
<p className="text-danger">
权限不足,只有管理员可以访问分组管理页面。
</p>
</CardBody>
</Card>
</div>
);
}
return (
<div className="px-3 lg:px-6 py-8 space-y-6">
{loading && (
<div className="flex justify-center py-10">
<Spinner size="lg" />
</div>
)}
<Card>
<CardHeader className="flex items-center justify-between">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button color="primary" size="sm" onPress={openCreateTunnelGroup}>
新建隧道分组
</Button>
</CardHeader>
<CardBody>
<Table aria-label="隧道分组列表">
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道分组" items={tunnelGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.tunnelNames.length > 0
? item.tunnelNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignTunnels(item)}
>
分配隧道
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditTunnelGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteTunnelGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader className="flex items-center justify-between">
<h3 className="text-lg font-semibold">用户分组</h3>
<Button color="primary" size="sm" onPress={openCreateUserGroup}>
新建用户分组
</Button>
</CardHeader>
<CardBody>
<Table aria-label="用户分组列表">
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无用户分组" items={userGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.userNames.length > 0
? item.userNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignUsers(item)}
>
分配用户
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditUserGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteUserGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">权限分配</h3>
</CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Select
items={userGroups}
label="用户分组"
selectedKeys={
selectedUserGroupId ? [String(selectedUserGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedUserGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Select
items={tunnelGroups}
label="隧道分组"
selectedKeys={
selectedTunnelGroupId ? [String(selectedTunnelGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedTunnelGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Button
color="primary"
isLoading={savingPermission}
onPress={handleAssignPermission}
>
分配权限
</Button>
</div>
<Table aria-label="分组权限列表">
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户分组</TableColumn>
<TableColumn>隧道分组</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无权限分配记录" items={permissions}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>
{item.userGroupName || item.userGroupId}
</TableCell>
<TableCell>
{item.tunnelGroupName || item.tunnelGroupId}
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleRemovePermission(item.id)}
>
回收
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Modal
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveTunnelGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal isOpen={userGroupModalOpen} onOpenChange={onUserGroupModalChange}>
<ModalContent>
<ModalHeader>
{editingUserGroup ? "编辑用户分组" : "新建用户分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveUserGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
<ModalContent>
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
<ModalBody>
<Select
items={tunnels}
label="选择隧道"
selectedKeys={selectedTunnelKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedTunnelKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<p className="text-xs text-default-500">
当前已选:
{Array.from(selectedTunnelKeys)
.map((id) => tunnelNameMap.get(Number(id)) || id)
.join("、") || "无"}
</p>
<p className="text-xs text-default-500">
不选择任何隧道并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignTunnels}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
<ModalContent>
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
<ModalBody>
<Select
items={users}
label="选择用户"
selectedKeys={selectedUserKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedUserKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
</Select>
<p className="text-xs text-default-500">
当前已选:
{Array.from(selectedUserKeys)
.map((id) => userNameMap.get(Number(id)) || id)
.join("、") || "无"}
</p>
<p className="text-xs text-default-500">
不选择任何用户并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignUsers}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
+2 -5
View File
@@ -156,8 +156,7 @@ export default function IndexPage() {
tacInstanceRef.current = new window.TAC(config, style);
tacInstanceRef.current.init();
} catch (error) {
console.error("初始化验证码失败:", error);
} catch {
toast.error("验证码初始化失败,请刷新页面重试");
setShowCaptcha(false);
setLoading(false);
@@ -202,8 +201,7 @@ export default function IndexPage() {
// 登录成功
toast.success("登录成功");
navigate("/dashboard");
} catch (error) {
console.error("登录错误:", error);
} catch {
toast.error("网络错误,请稍后重试");
} finally {
setLoading(false);
@@ -239,7 +237,6 @@ export default function IndexPage() {
}, 100);
}
} catch (error) {
console.error("检查验证码状态错误:", error);
toast.error("网络错误,请稍后重试" + error);
setLoading(false);
}
+74 -62
View File
@@ -94,10 +94,8 @@ export default function LimitPage() {
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
} else {
console.warn("获取隧道列表失败:", tunnelsRes.msg);
}
} catch (error) {
console.error("加载数据失败:", error);
} catch {
toast.error("加载数据失败");
} finally {
setLoading(false);
@@ -177,8 +175,7 @@ export default function LimitPage() {
} else {
toast.error(res.msg || "删除失败");
}
} catch (error) {
console.error("删除失败:", error);
} catch {
toast.error("删除失败");
} finally {
setDeleteLoading(false);
@@ -196,7 +193,9 @@ export default function LimitPage() {
if (isEdit) {
res = await updateSpeedLimit(form);
} else {
const { id, ...createData } = form;
const createData = { ...form };
delete createData.id;
res = await createSpeedLimit(createData);
}
@@ -208,8 +207,7 @@ export default function LimitPage() {
} else {
toast.error(res.msg || "操作失败");
}
} catch (error) {
console.error("提交失败:", error);
} catch {
toast.error("操作失败");
} finally {
setSubmitLoading(false);
@@ -233,7 +231,7 @@ export default function LimitPage() {
<div className="flex items-center justify-between mb-6">
<div className="flex-1" />
<Button size="sm" variant="flat" color="primary" onPress={handleAdd}>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
@@ -255,8 +253,8 @@ export default function LimitPage() {
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
variant="flat"
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
@@ -291,24 +289,26 @@ export default function LimitPage() {
<div className="flex gap-2 mt-4">
<Button
className="flex-1"
color="primary"
size="sm"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
variant="flat"
onPress={() => handleEdit(rule)}
color="primary"
size="sm"
startContent={
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(rule)}
className="flex-1"
color="danger"
size="sm"
startContent={
<svg
className="w-4 h-4"
@@ -316,17 +316,19 @@ export default function LimitPage() {
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
clipRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
fillRule="evenodd"
/>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
fillRule="evenodd"
/>
</svg>
}
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
@@ -348,10 +350,10 @@ export default function LimitPage() {
viewBox="0 0 24 24"
>
<path
d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
@@ -370,12 +372,12 @@ export default function LimitPage() {
{/* 新增/编辑模态框 */}
<Modal
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="2xl"
scrollBehavior="outside"
backdrop="blur"
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onOpenChange={setModalOpen}
>
<ModalContent>
{(onClose) => (
@@ -394,36 +396,50 @@ export default function LimitPage() {
<div className="space-y-4">
<Input
errorMessage={errors.name}
isInvalid={!!errors.name}
label="规则名称"
placeholder="请输入限速规则名称"
value={form.name}
variant="bordered"
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
isInvalid={!!errors.name}
label="规则名称"
placeholder="请输入限速规则名称"
value={form.name}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
/>
<Input
endContent={
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">Mbps</span>
</div>
}
errorMessage={errors.speed}
isInvalid={!!errors.speed}
label="速度限制"
placeholder="请输入速度限制"
type="number"
value={form.speed.toString()}
variant="bordered"
onChange={(e) => setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))}
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">
Mbps
</span>
</div>
}
errorMessage={errors.speed}
isInvalid={!!errors.speed}
label="速度限制"
placeholder="请输入速度限制"
type="number"
value={form.speed.toString()}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
speed: parseInt(e.target.value) || 0,
}))
}
/>
<Select
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
errorMessage={errors.tunnelId}
isDisabled={isEdit}
isInvalid={!!errors.tunnelId}
label="绑定隧道"
placeholder="请选择要绑定的隧道"
selectedKeys={
form.tunnelId ? [form.tunnelId.toString()] : []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
@@ -431,6 +447,7 @@ export default function LimitPage() {
const selectedTunnel = tunnels.find(
(tunnel) => tunnel.id === parseInt(selectedKey),
);
setForm((prev) => ({
...prev,
tunnelId: parseInt(selectedKey),
@@ -444,11 +461,6 @@ export default function LimitPage() {
}));
}
}}
isInvalid={!!errors.tunnelId}
errorMessage={errors.tunnelId}
variant="bordered"
isDisabled={isEdit}
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>
@@ -462,8 +474,8 @@ export default function LimitPage() {
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={submitLoading}
onPress={handleSubmit}
>
{isEdit ? "保存修改" : "创建规则"}
</Button>
@@ -475,12 +487,12 @@ export default function LimitPage() {
{/* 删除确认模态框 */}
<Modal
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="2xl"
scrollBehavior="outside"
backdrop="blur"
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onOpenChange={setDeleteModalOpen}
>
<ModalContent>
{(onClose) => (
@@ -492,7 +504,7 @@ export default function LimitPage() {
<p className="text-default-600">
确定要删除限速规则{" "}
<span className="font-semibold text-foreground">
"{ruleToDelete?.name}"
&quot;{ruleToDelete?.name}&quot;
</span>{" "}
吗?
</p>
@@ -506,8 +518,8 @@ export default function LimitPage() {
</Button>
<Button
color="danger"
onPress={confirmDelete}
isLoading={deleteLoading}
onPress={confirmDelete}
>
确认删除
</Button>
+163 -21
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("");
@@ -259,7 +266,7 @@ export default function NodePage() {
} else {
toast.error(res.msg || "加载节点列表失败");
}
} catch (error) {
} catch {
toast.error("网络错误,请重试");
} finally {
setLoading(false);
@@ -305,7 +312,7 @@ export default function NodePage() {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (error) {
} catch {
// 解析失败时不输出错误信息
}
};
@@ -320,7 +327,7 @@ export default function NodePage() {
setWsConnecting(false);
attemptReconnect();
};
} catch (error) {
} catch {
setWsConnected(false);
setWsConnecting(false);
attemptReconnect();
@@ -406,7 +413,7 @@ export default function NodePage() {
uptime: currentUptime,
},
};
} catch (error) {
} catch {
return node;
}
}
@@ -710,7 +717,7 @@ export default function NodePage() {
} else {
toast.error(res.msg || "删除失败");
}
} catch (error) {
} catch {
toast.error("网络错误,请重试");
} finally {
setDeleteLoading(false);
@@ -730,7 +737,7 @@ export default function NodePage() {
try {
await navigator.clipboard.writeText(res.data);
toast.success("安装命令已复制到剪贴板");
} catch (copyError) {
} catch {
// 复制失败,显示安装命令模态框
setInstallCommand(res.data);
setCurrentNodeName(node.name);
@@ -739,7 +746,7 @@ export default function NodePage() {
} else {
toast.error(res.msg || "获取安装命令失败");
}
} catch (error) {
} catch {
toast.error("获取安装命令失败");
} finally {
setNodeList((prev) =>
@@ -754,7 +761,7 @@ export default function NodePage() {
await navigator.clipboard.writeText(installCommand);
toast.success("安装命令已复制到剪贴板");
setInstallCommandModal(false);
} catch (error) {
} catch {
toast.error("复制失败,请手动选择文本复制");
}
};
@@ -767,13 +774,13 @@ export default function NodePage() {
try {
const apiCall = isEdit ? updateNode : createNode;
const { serverHost: _serverHost, ...rest } = form;
const { serverHost, ...rest } = form;
const data = {
...rest,
serverIp:
form.serverIpV4?.trim() ||
form.serverIpV6?.trim() ||
form.serverHost?.trim() ||
serverHost?.trim() ||
"",
};
@@ -814,7 +821,7 @@ export default function NodePage() {
} else {
toast.error(res.msg || (isEdit ? "更新失败" : "创建失败"));
}
} catch (error) {
} catch {
toast.error("网络错误,请重试");
} finally {
setSubmitLoading(false);
@@ -864,9 +871,7 @@ export default function NodePage() {
// 保存到 localStorage
try {
localStorage.setItem("node-order", JSON.stringify(newOrder));
} catch (error) {
console.warn("无法保存节点排序到localStorage:", error);
}
} catch {}
// 持久化到数据库
try {
@@ -884,12 +889,66 @@ export default function NodePage() {
} else {
toast.error("保存排序失败:" + (response.msg || "未知错误"));
}
} catch (error) {
console.error("保存节点排序到数据库失败:", error);
} catch {
toast.error("保存排序失败,请重试");
}
};
// 批量操作处理函数
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 {
toast.error("网络错误,请重试");
} finally {
setBatchLoading(false);
}
};
// 传感器配置
const sensors = useSensors(
useSensor(MouseSensor, {
@@ -957,11 +1016,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 +1121,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>
@@ -1625,7 +1725,8 @@ export default function NodePage() {
</ModalHeader>
<ModalBody>
<p>
确定要删除节点 <strong>"{nodeToDelete?.name}"</strong> 吗?
确定要删除节点{" "}
<strong>&quot;{nodeToDelete?.name}&quot;</strong> 吗?
</p>
<p className="text-small text-default-500">
此操作不可恢复,请谨慎操作。
@@ -1701,6 +1802,47 @@ 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>
);
}
+1 -2
View File
@@ -168,9 +168,8 @@ export default function ProfilePage() {
} else {
toast.error(response.msg || "密码修改失败");
}
} catch (error) {
} catch {
toast.error("修改密码时发生错误");
console.error("修改密码错误:", error);
} finally {
setPasswordLoading(false);
}
+189 -19
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();
}, []);
@@ -211,10 +220,8 @@ export default function TunnelPage() {
if (nodesRes.code === 0) {
setNodes(nodesRes.data || []);
} else {
console.warn("获取节点列表失败:", nodesRes.msg);
}
} catch (error) {
console.error("加载数据失败:", error);
} catch {
toast.error("加载数据失败");
} finally {
setLoading(false);
@@ -347,8 +354,7 @@ export default function TunnelPage() {
} else {
toast.error(response.msg || "删除失败");
}
} catch (error) {
console.error("删除失败:", error);
} catch {
toast.error("删除失败");
} finally {
setDeleteLoading(false);
@@ -489,8 +495,7 @@ export default function TunnelPage() {
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
} catch (error) {
console.error("提交失败:", error);
} catch {
toast.error("网络错误,请重试");
} finally {
setSubmitLoading(false);
@@ -528,8 +533,7 @@ export default function TunnelPage() {
],
});
}
} catch (error) {
console.error("诊断失败:", error);
} catch {
toast.error("网络错误,请重试");
setDiagnosisResult({
tunnelName: tunnel.name,
@@ -618,9 +622,7 @@ export default function TunnelPage() {
// 保存到 localStorage
try {
localStorage.setItem("tunnel-order", JSON.stringify(newOrder));
} catch (error) {
console.warn("无法保存隧道排序到localStorage:", error);
}
} catch {}
// 持久化到数据库
try {
@@ -638,12 +640,98 @@ export default function TunnelPage() {
} else {
toast.error("保存排序失败:" + (response.msg || "未知错误"));
}
} catch (error) {
console.error("保存隧道排序到数据库失败:", error);
} catch {
toast.error("保存排序失败,请重试");
}
};
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 +840,54 @@ 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
color={selectMode ? "warning" : "default"}
size="sm"
variant={selectMode ? "solid" : "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 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
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="primary"
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
</div>
</div>
)}
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
@@ -777,6 +908,13 @@ export default function TunnelPage() {
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
{selectMode && (
<Checkbox
className="mr-2"
isSelected={selectedIds.has(tunnel.id)}
onValueChange={() => toggleSelect(tunnel.id)}
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">
{tunnel.name}
@@ -1532,7 +1670,7 @@ export default function TunnelPage() {
{getChainGroups().length === 0 && (
<div className="text-center py-8 bg-default-50 dark:bg-default-100/50 rounded border border-dashed border-default-300">
<p className="text-sm text-default-500">
还没有添加转发链,点击上方"添加一跳"按钮开始添加
还没有添加转发链,点击上方&quot;添加一跳&quot;按钮开始添加
</p>
</div>
)}
@@ -1838,7 +1976,8 @@ export default function TunnelPage() {
</ModalHeader>
<ModalBody>
<p>
确定要删除隧道 <strong>"{tunnelToDelete?.name}"</strong> 吗?
确定要删除隧道{" "}
<strong>&quot;{tunnelToDelete?.name}&quot;</strong> 吗?
</p>
<p className="text-small text-default-500">
此操作不可恢复,请谨慎操作。
@@ -2377,6 +2516,37 @@ 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"
isLoading={batchLoading}
onPress={handleBatchDelete}
>
确认删除
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
);
}
+30 -20
View File
@@ -229,7 +229,7 @@ export default function UserPage() {
} else {
toast.error(response.msg || "获取用户列表失败");
}
} catch (error) {
} catch {
toast.error("获取用户列表失败");
} finally {
setLoading(false);
@@ -243,9 +243,7 @@ export default function UserPage() {
if (response.code === 0) {
setTunnels(response.data || []);
}
} catch (error) {
console.error("获取隧道列表失败:", error);
}
} catch {}
};
const loadSpeedLimits = async () => {
@@ -255,9 +253,7 @@ export default function UserPage() {
if (response.code === 0) {
setSpeedLimits(response.data || []);
}
} catch (error) {
console.error("获取限速规则列表失败:", error);
}
} catch {}
};
const loadUserTunnels = async (userId: number) => {
@@ -270,7 +266,7 @@ export default function UserPage() {
} else {
toast.error(response.msg || "获取隧道权限列表失败");
}
} catch (error) {
} catch {
toast.error("获取隧道权限列表失败");
} finally {
setTunnelListLoading(false);
@@ -332,7 +328,7 @@ export default function UserPage() {
} else {
toast.error(response.msg || "删除失败");
}
} catch (error) {
} catch {
toast.error("删除失败");
}
};
@@ -366,7 +362,7 @@ export default function UserPage() {
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
} catch (error) {
} catch {
toast.error(isEdit ? "更新失败" : "创建失败");
} finally {
setUserFormLoading(false);
@@ -406,7 +402,7 @@ export default function UserPage() {
} else {
toast.error(response.msg || "分配失败");
}
} catch (error) {
} catch {
toast.error("分配失败");
} finally {
setAssignLoading(false);
@@ -445,7 +441,7 @@ export default function UserPage() {
} else {
toast.error(response.msg || "更新失败");
}
} catch (error) {
} catch {
toast.error("更新失败");
} finally {
setEditTunnelLoading(false);
@@ -473,7 +469,7 @@ export default function UserPage() {
} else {
toast.error(response.msg || "删除失败");
}
} catch (error) {
} catch {
toast.error("删除失败");
}
};
@@ -502,7 +498,7 @@ export default function UserPage() {
} else {
toast.error(response.msg || "重置失败");
}
} catch (error) {
} catch {
toast.error("重置失败");
} finally {
setResetFlowLoading(false);
@@ -535,7 +531,7 @@ export default function UserPage() {
} else {
toast.error(response.msg || "重置失败");
}
} catch (error) {
} catch {
toast.error("重置失败");
} finally {
setResetTunnelFlowLoading(false);
@@ -1005,6 +1001,7 @@ export default function UserPage() {
return (
<div
key={tunnel.id}
aria-disabled={isAssigned}
className={`
px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer
${
@@ -1015,9 +1012,21 @@ export default function UserPage() {
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
}
`}
role="button"
tabIndex={isAssigned ? -1 : 0}
onClick={() =>
!isAssigned && toggleTunnelSelection(tunnel.id)
}
onKeyDown={(event) => {
if (isAssigned) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleTunnelSelection(tunnel.id);
}
}}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
@@ -1051,7 +1060,7 @@ export default function UserPage() {
</div>
{isSelected && !isAssigned && (
<div onClick={(e) => e.stopPropagation()}>
<div>
<Select
className="w-36"
classNames={{
@@ -1071,6 +1080,7 @@ export default function UserPage() {
: ["null"]
}
size="sm"
onClick={(e) => e.stopPropagation()}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as string;
@@ -1455,7 +1465,7 @@ export default function UserPage() {
<p className="text-foreground">
确定要删除用户{" "}
<span className="font-semibold text-danger">
"{userToDelete?.user}"
&quot;{userToDelete?.user}&quot;
</span>{" "}
吗?
</p>
@@ -1500,7 +1510,7 @@ export default function UserPage() {
<span className="font-semibold">{currentUser?.user}</span>{" "}
对隧道{" "}
<span className="font-semibold text-danger">
"{tunnelToDelete?.tunnelName}"
&quot;{tunnelToDelete?.tunnelName}&quot;
</span>{" "}
的权限吗?
</p>
@@ -1553,7 +1563,7 @@ export default function UserPage() {
<p className="text-foreground">
确定要重置用户{" "}
<span className="font-semibold text-warning">
"{userToReset?.user}"
&quot;{userToReset?.user}&quot;
</span>{" "}
的流量吗?
</p>
@@ -1643,7 +1653,7 @@ export default function UserPage() {
<span className="font-semibold">{currentUser?.user}</span>{" "}
对隧道{" "}
<span className="font-semibold text-warning">
"{tunnelToReset?.tunnelName}"
&quot;{tunnelToReset?.tunnelName}&quot;
</span>{" "}
的流量吗?
</p>
+8 -5
View File
@@ -2,7 +2,7 @@
declare global {
interface Window {
TAC: typeof TAC;
TAC: TACConstructor;
currentCaptcha?: any;
currentCaptchaRes?: any;
}
@@ -42,13 +42,16 @@ interface CaptchaStyle {
moveTrackMaskBorderColor?: string;
}
declare class TAC {
constructor(config: CaptchaConfig, style?: CaptchaStyle);
init(): TAC;
interface TACInstance {
init(): TACInstance;
reloadCaptcha(): void;
destroyWindow(): void;
openCaptcha(): void;
}
type TACConstructor = new (
config: CaptchaConfig,
style?: CaptchaStyle,
) => TACInstance;
export {};
+2 -4
View File
@@ -60,17 +60,15 @@ export function isLoggedIn(): boolean {
/**
* 权限检查装饰器函数
* @param fn 要执行的函数
* @param errorMsg 权限不足时的错误提示
* @param _errorMsg 权限不足时的错误提示
* @returns 包装后的函数
*/
export function requireAdmin<T extends (...args: any[]) => any>(
fn: T,
errorMsg: string = "权限不足,仅管理员可操作",
_errorMsg: string = "权限不足,仅管理员可操作",
): T {
return ((...args: Parameters<T>) => {
if (!isAdmin()) {
console.warn(errorMsg);
return false;
}
+1 -1
View File
@@ -27,7 +27,7 @@ function getPayloadFromToken(token: string): JWTPayload | null {
const decodedPayload = atob(encodedPayload);
return JSON.parse(decodedPayload) as JWTPayload;
} catch (error) {
} catch {
return null;
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ export function validatePanelAddress(address: string): boolean {
}
return false;
} catch (error) {
} catch {
// URL构造函数失败说明格式不正确
return false;
}