Compare commits

..

11 Commits

Author SHA1 Message Date
root 72c2e28667 fix: 移除未使用的变量修复TypeScript编译错误 2026-02-03 15:55:30 +00:00
sagit 74c78851ca Merge pull request #11 from Sagit-chu/opencode/neon-rocket
feat: 简化用户隧道权限分配,支持多选隧道批量分配 (#8)
2026-02-03 23:28:55 +08:00
root 42732f844a fix: 为嵌套的TunnelAssignItem添加@Valid注解确保校验生效 2026-02-03 15:16:43 +00:00
root 99b8ac206a fix: 批量分配时对请求中的重复tunnelId进行去重
防止同一请求中包含重复tunnelId导致创建多条权限记录
2026-02-03 14:43:34 +00:00
root 0e5cd86ed1 feat: 简化用户隧道权限分配,支持多选隧道批量分配 (#8)
- 新增批量分配接口 POST /tunnel/user/batch-assign
- 支持一次选择多个隧道进行分配
- 每个隧道可单独设置限速规则
- flow/num/flowResetTime/expTime 自动从用户设置继承
- 前端表单简化为隧道多选列表+限速选择
- 已分配的隧道显示'已分配'标记且不可重复选择
2026-02-03 13:59:05 +00:00
root 60fc80b6ac fix: 节点更新时serverIpV4/V6/域名字段无法正确清空的问题
使用LambdaUpdateWrapper替代updateById以确保null值能正确更新到数据库

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-03 12:57:39 +00:00
root c8f0c55fda fix: 隧道新增入口节点后转发管理的入口IP不自动更新 2026-02-03 11:30:31 +00:00
root bfdaa47ea5 修复隧道编辑新增入口节点后转发规则不生效的问题 (#9)
当编辑隧道添加新入口节点时,现有转发规则的ForwardPort和GOST服务未同步更新,
导致新入口节点的端口实际不可用。

修复方案:在updateTunnel成功更新节点后,同步更新所有使用该隧道的转发规则:
- 对移除的入口节点:删除ForwardPort记录和GOST服务
- 对新增的入口节点:分配端口、创建ForwardPort记录和GOST服务
2026-02-03 09:28:38 +00:00
sagit d7b76b4590 Update README.md 2026-02-02 15:51:24 +08:00
root 2c2262b55d 完善分层 AGENTS.md,便于快速定位代码 2026-02-02 07:41:27 +00:00
root 7ca01aba5d 修复转发管理隧道下拉顺序不同步 2026-02-02 06:28:45 +00:00
25 changed files with 958 additions and 402 deletions
+45 -22
View File
@@ -1,43 +1,66 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Sat Jan 24 2026
**Context:** Monorepo for Flux Panel (Traffic Forwarding)
**Generated:** Mon Feb 02 2026
**Commit:** 7ca01ab
**Branch:** beta
## OVERVIEW
Flux Panel is a traffic forwarding management system based on [go-gost](https://github.com/go-gost/gost). It manages tunnels, port forwarding, and user quotas.
**Stack:** Monorepo (Java/Spring Boot Backend + React/Vite Frontend + Go/GOST Service).
Flux Panel is a traffic forwarding management system built on a forked GOST v3 stack. It ships as Dockerized Spring Boot (admin API) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
/root/flux-panel/
├── springboot-backend/ # Java 21 + Spring Boot 2.7 Admin API
├── vite-frontend/ # React 18 + Vite + HeroUI/NextUI
├── go-gost/ # Go 1.23 + GOST Extensions (Core logic)
├── docker-compose*.yml # Deployment configs (v4/v6)
└── *.sh # Install scripts (panel_install.sh, install.sh)
./
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── springboot-backend/ # Java/Spring Boot admin API (SQLite/MyBatis)
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
├── android-app/ # Android WebView wrapper (optional)
├── ios-app/ # iOS WebView wrapper (optional)
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
├── panel_install.sh # Panel installer/upgrader (downloads compose)
├── install.sh # Node installer/upgrader (downloads gost binary)
└── .github/workflows/ # CI: build/push images + release artifacts
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Admin API** | `springboot-backend/` | Users, quotas, billing logic |
| **UI/Dashboard** | `vite-frontend/` | Management console |
| **Core Forwarding** | `go-gost/` | GOST implementation & extensions |
| **Deploy** | `docker-compose-v4.yml` | Container orchestration |
| **Deploy (Docker)** | `docker-compose-v4.yml` | Env: `JWT_SECRET`, `BACKEND_PORT`, `FRONTEND_PORT` |
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API entry** | `springboot-backend/src/main/java/com/admin/AdminApplication.java` | Spring Boot app |
| **Admin API routes** | `springboot-backend/src/main/java/com/admin/controller/` | Mostly `/api/v1/*` controllers |
| **Admin auth** | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Checks `Authorization` header |
| **Web UI routing** | `vite-frontend/src/App.tsx` | React Router v6 + ProtectedRoute/H5 layouts |
| **Web UI API client** | `vite-frontend/src/api/network.ts` | Axios `baseURL` + `Authorization` header |
| **Go agent entry** | `go-gost/main.go` | Reads panel `config.json` + starts gost services |
| **Go x fork** | `go-gost/x/` | Handlers/listeners/dialers + management API |
## CONVENTIONS
- **Monorepo**: 3 distinct languages/stacks. Treat each subdir as a separate project.
- **Docker**: Primary deployment method.
- **Scripts**: `panel_install.sh` for panel, `install.sh` for nodes.
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
## ANTI-PATTERNS (THIS PROJECT)
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
## COMMANDS
```bash
# Quick Deploy (Panel)
./panel_install.sh
# Panel (Docker)
docker compose -f docker-compose-v4.yml up -d
docker compose -f docker-compose-v6.yml up -d
# Quick Deploy (Node)
# Release-based install scripts
./panel_install.sh
./install.sh
# Docker
docker-compose -f docker-compose-v4.yml up -d
# Local dev (per subproject)
(cd springboot-backend && mvn clean package)
(cd vite-frontend && npm run dev)
(cd go-gost && go run .)
```
## NOTES
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
+11 -1
View File
@@ -66,4 +66,14 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/install.sh -
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
请务必在合法、合规、安全的前提下使用本项目。
请务必在合法、合规、安全的前提下使用本项目。
---
## ⭐ 喝杯咖啡!(USDT)
| 网络 | 地址 |
|------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
+21 -15
View File
@@ -1,31 +1,37 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Core forwarding service based on GOST v3.
**Stack:** Go 1.23, GOST Core v0.3.1, GOST x (Extensions).
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
**Stack:** Go 1.23, github.com/go-gost/core v0.3.1, local `go-gost/x` module.
## STRUCTURE
```
go-gost/
├── main.go # Entry point
├── x/ # Local extensions (REPLACES github.com/go-gost/x)
│ ├── api/ # Management API
│ ├── registry/ # Service registry
│ ├── handler/ # Protocol handlers (socks, tunnel, relay)
│ └── listener/ # Network listeners (tcp, udp, tun/tap)
└── go.mod # Defines local replacement
├── main.go # Entry; reads panel config.json; starts svc.Run(program)
├── config.go # Panel config.json loader (addr/secret + ports)
├── program.go # GOST runtime: parse config, run/reload services
├── x/ # Local fork of github.com/go-gost/x (has its own go.mod)
└── go.mod # replace github.com/go-gost/x => ./x
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
## CONVENTIONS
- **Local Replace**: `go.mod` uses `replace github.com/go-gost/x => ./x`.
- **Extensions**: Custom logic lives in `x/`. This is the primary place for modifications.
- **Handlers**: Implements SOCKS5, Tunnel, Relay, etc.
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
## COMMANDS
```bash
# Run
cd go-gost
go run .
# Build
go test ./...
go build .
```
+42
View File
@@ -0,0 +1,42 @@
# GO-GOST/X KNOWLEDGE BASE
## OVERVIEW
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here.
## STRUCTURE
```
go-gost/x/
├── api/ # Gin management API + embedded swagger docs
├── config/ # Config model + parsing/load/reload
├── connector/ # Outbound connect implementations
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
├── limiter/ # Traffic/rate/conn limiters
├── registry/ # Registries for services/handlers/listeners/etc
├── service/ # Service wrappers + reporting hooks
├── socket/ # WebSocket reporter / panel integration
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
## CONVENTIONS
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
## ANTI-PATTERNS
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+23
View File
@@ -0,0 +1,23 @@
# GO-GOST/X API KNOWLEDGE BASE
## OVERVIEW
Gin-based management API for reading/writing config and controlling services at runtime.
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Route registration | `go-gost/x/api/api.go` | `Register(*gin.Engine, *Options)` |
| Auth gating | `go-gost/x/api/middleware.go` | Drops non-BasicAuth requests; optional auther check |
| Service CRUD + pause/resume | `go-gost/x/api/config_service.go` | Uses registry + `config.OnUpdate(...)` |
| Swagger spec | `go-gost/x/api/swagger.yaml` | Served at `/docs` via embedded FS |
## CONVENTIONS
- CORS is `AllowAllOrigins: true` (see `go-gost/x/api/api.go`).
- Requests without a valid Basic `Authorization` header are silently dropped (connection hijack + close) by `GlobalInterceptor()`.
- Many operations mutate the in-memory config via `config.OnUpdate(...)` after starting/stopping services.
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+23
View File
@@ -0,0 +1,23 @@
# GO-GOST/X CONFIG KNOWLEDGE BASE
## OVERVIEW
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Config structs + global state | `go-gost/x/config/config.go` | `Global()`, `Set()`, `OnUpdate()` |
| Default config file search | `go-gost/x/config/config.go` | Viper `SetConfigName("gost")` + paths `/etc/gost/`, `$HOME/.gost/`, `.` |
| Registry wiring | `go-gost/x/config/loader/loader.go` | Parses config sections and registers into registries |
| Metadata keys | `go-gost/x/config/parsing/parse.go` | `MDKey*` constants used by parsers |
| Config parser behavior | `go-gost/x/config/parsing/parser/parser.go` | CLI/env overrides; loads `gost.*` when empty |
## CONVENTIONS
- Default config file is named `gost` (e.g. `gost.json`) and is discovered via viper search paths.
- Runtime config mutations should go through `config.OnUpdate(...)` so changes are applied under the global mutex.
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+35
View File
@@ -0,0 +1,35 @@
# GO-GOST/X DIALERS KNOWLEDGE BASE
## OVERVIEW
Outbound dialers (client-side connection establishment) used by connectors/handlers.
## STRUCTURE
```
go-gost/x/dialer/
├── direct/ # Baseline dialer
├── tcp/
├── udp/
├── tls/
├── ws/
├── quic/
├── http2/
├── http3/
├── ssh/
├── wg/ # WireGuard dialer
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Pick a dialer | `go-gost/x/dialer/` | One subdir per transport |
| TCP baseline | `go-gost/x/dialer/tcp/dialer.go` | Reference implementation |
## CONVENTIONS
- Dialer implementations typically live in `dialer.go` with a paired `metadata.go` (e.g. `go-gost/x/dialer/tcp/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+32
View File
@@ -0,0 +1,32 @@
# GO-GOST/X HANDLERS KNOWLEDGE BASE
## OVERVIEW
Protocol handlers (server-side request handling) used by services defined in the GOST config.
## STRUCTURE
```
go-gost/x/handler/
├── http/ # handler.go + metadata.go (+ udp.go)
├── socks/ # SOCKS variants
├── tunnel/ # Tunnel forwarding
├── relay/ # Relay forwarding
├── redirect/ # TCP/UDP redirect handlers
├── router/ # Routing/association entrypoints
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Find a protocol handler | `go-gost/x/handler/` | Subdir per protocol (`http`, `socks`, `tunnel`, ...) |
| HTTP specifics | `go-gost/x/handler/http/handler.go` | Implements HTTP proxy behavior |
| SOCKS specifics | `go-gost/x/handler/socks/` | v4/v5 implementations |
## CONVENTIONS
- Handler implementations typically live in `handler.go` with a paired `metadata.go` (e.g. `go-gost/x/handler/http/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+35
View File
@@ -0,0 +1,35 @@
# GO-GOST/X LISTENERS KNOWLEDGE BASE
## OVERVIEW
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
## STRUCTURE
```
go-gost/x/listener/
├── tcp/ # listener.go + metadata.go
├── udp/
├── tls/
├── ws/
├── quic/
├── redirect/ # tcp/ + udp/
├── tun/ # TUN device listener
├── tap/ # TAP device listener
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Listener registry | `go-gost/x/listener/` | One subdir per transport |
| TCP baseline | `go-gost/x/listener/tcp/listener.go` | Reference for other transports |
| Redirect listeners | `go-gost/x/listener/redirect/` | Per-protocol accept + redirect |
| TUN/TAP | `go-gost/x/listener/tun/`, `go-gost/x/listener/tap/` | Virtual interface listeners |
## CONVENTIONS
- Listener implementations typically live in `listener.go` with a paired `metadata.go` (e.g. `go-gost/x/listener/tcp/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+21 -19
View File
@@ -1,37 +1,39 @@
# SPRINGBOOT BACKEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Admin API for Flux Panel. Manages users, licenses, and traffic rules.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus.
Admin API for Flux Panel. Manages users, tunnels, nodes, forwards, quotas, and speed limits.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus (+ join), FastJSON2.
## STRUCTURE
```
springboot-backend/
├── src/main/java/com/admin/
│ ├── controller/ # API Endpoints
│ ├── entity/ # DB Models (MyBatis Plus)
│ ├── mapper/ # Data Access
│ ├── service/ # Business Logic
│ └── common/ # Utils, DTOs
│ ├── controller/ # /api/v1/* endpoints
│ ├── entity/ # DB models
│ ├── mapper/ # MyBatis Plus mappers
│ ├── service/ # Business logic
│ ├── config/ # WebMvc/JWT/CORS/WebSocket config
│ └── common/ # DTOs, auth, exception handling, utilities
└── src/main/resources/
├── application.yml # Config
├── mapper/ # XML Mappers
├── data.sql # Init data
└── bgimages/ # Static resources
├── application.yml # Config (DB_PATH/JWT_SECRET/LOG_DIR)
├── mapper/ # XML mappers
├── schema.sql # Schema
└── data.sql # Seed data
```
## CONVENTIONS
- **DB**: SQLite used via `sqlite-jdbc`.
- **ORM**: MyBatis Plus + MyBatis Plus Join.
- **JSON**: FastJSON2 used for serialization.
- **Utils**: Hutool used extensively.
- **Auth**: Likely custom or token-based (see `controller` logic).
- **DB**: SQLite URL is `jdbc:sqlite:${DB_PATH:/app/data/gost.db}` (`springboot-backend/src/main/resources/application.yml`).
- **Auth**: JWT in `Authorization` header; enforced by `com.admin.common.interceptor.JwtInterceptor` for `/api/**` (with explicit excludes in `com.admin.config.WebMvcConfig`).
- **Roles**: `@RequireRole` means admin-only (`role_id == 0`) via `com.admin.common.aop.RoleAspect`.
- **Responses**: Controllers return `com.admin.common.lang.R` (`code == 0` success).
- **CORS**: Allow-all origins; `Authorization` is exposed (`com.admin.config.WebMvcConfig`).
## COMMANDS
```bash
# Build
cd springboot-backend
mvn clean package
# Run
mvn test
java -jar target/admin-0.0.1-SNAPSHOT.jar
```
@@ -0,0 +1,32 @@
# SPRINGBOOT BACKEND (com.admin) KNOWLEDGE BASE
## OVERVIEW
Primary Java code for the admin API. Controllers expose `/api/v1/*` endpoints and return `R` response envelopes.
## STRUCTURE
```
springboot-backend/src/main/java/com/admin/
├── controller/ # REST controllers (e.g., /api/v1/user)
├── service/ # Business logic interfaces + impl/
├── mapper/ # MyBatis Plus mappers
├── entity/ # DB entities
├── config/ # WebMvc/JWT/CORS/WebSocket config
└── common/ # DTOs, auth, exception handling, utilities
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| User/login endpoints | `springboot-backend/src/main/java/com/admin/controller/UserController.java` | `/api/v1/user/*` |
| Auth enforcement | `springboot-backend/src/main/java/com/admin/config/WebMvcConfig.java` | Intercepts `/api/**`, excludes login/config/captcha |
| JWT validation | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Requires `Authorization` header |
| Admin-only ops | `springboot-backend/src/main/java/com/admin/common/annotation/RequireRole.java` | Enforced by `RoleAspect` |
| Response envelope | `springboot-backend/src/main/java/com/admin/common/lang/R.java` | `code == 0` success |
| Global error handling | `springboot-backend/src/main/java/com/admin/common/exception/GlobalExceptionHandler.java` | Maps exceptions -> `R.err(...)` |
## CONVENTIONS
- Controllers are mostly `@PostMapping` (even for list/get/delete) and use `/api/v1/*` prefixes.
- JWT is custom (no 3p lib) and includes `role_id` in payload (`springboot-backend/src/main/java/com/admin/common/utils/JwtUtil.java`).
## ANTI-PATTERNS
- Do not change auth header format lightly: frontend expects `Authorization: <token>` (no `Bearer`).
@@ -0,0 +1,26 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotEmpty;
import java.util.List;
@Data
public class UserTunnelBatchAssignDto {
@NotNull(message = "用户ID不能为空")
private Integer userId;
@Valid
@NotEmpty(message = "隧道列表不能为空")
private List<TunnelAssignItem> tunnels;
@Data
public static class TunnelAssignItem {
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
private Integer speedId;
}
}
@@ -13,28 +13,15 @@ public class UserTunnelDto {
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
@NotNull(message = "流量限制不能为空")
@Min(value = 0, message = "流量限制不能小于0")
private Long flow;
@NotNull(message = "转发数量不能为空")
@Min(value = 0, message = "转发数量不能小于0")
private Integer num;
/**
* 流量重置时间(时间戳)
*/
@NotNull(message = "流量重置时间不能为空")
private Long flowResetTime;
/**
* 到期时间(时间戳)
*/
@NotNull(message = "到期时间不能为空")
private Long expTime;
/**
* 限速规则ID(可选,null表示不限速)
*/
private Integer speedId;
}
}
@@ -5,6 +5,7 @@ import com.admin.common.annotation.RequireRole;
import com.admin.common.dto.TunnelDto;
import com.admin.common.dto.TunnelUpdateDto;
import com.admin.common.dto.UserTunnelBatchAssignDto;
import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.dto.UserTunnelUpdateDto;
@@ -78,6 +79,13 @@ public class TunnelController extends BaseController {
public R assignUserTunnel(@Validated @RequestBody UserTunnelDto userTunnelDto) {
return userTunnelService.assignUserTunnel(userTunnelDto);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/batch-assign")
public R batchAssignUserTunnel(@Validated @RequestBody UserTunnelBatchAssignDto batchAssignDto) {
return userTunnelService.batchAssignUserTunnel(batchAssignDto);
}
/**
* 查询用户隧道权限列表
@@ -1,49 +1,23 @@
package com.admin.service;
import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.dto.UserTunnelUpdateDto;
import com.admin.common.lang.R;
import com.admin.entity.UserTunnel;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p>
* 用户隧道权限服务类
* </p>
*
* @author QAQ
* @since 2025-06-03
*/
public interface UserTunnelService extends IService<UserTunnel> {
/**
* 分配用户隧道权限
* @param userTunnelDto 用户隧道权限数据
* @return 结果
*/
R assignUserTunnel(UserTunnelDto userTunnelDto);
/**
* 查询用户隧道权限列表
* @param queryDto 查询条件
* @return 结果
*/
R getUserTunnelList(UserTunnelQueryDto queryDto);
/**
* 删除用户隧道权限
* @param id ID
* @return 结果
*/
R removeUserTunnel(Integer id);
/**
* 更新用户隧道权限(包含流量、流量重置时间、到期时间)
* @param updateDto 更新数据
* @return 结果
*/
R updateUserTunnel(UserTunnelUpdateDto updateDto);
}
package com.admin.service;
import com.admin.common.dto.UserTunnelBatchAssignDto;
import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.dto.UserTunnelUpdateDto;
import com.admin.common.lang.R;
import com.admin.entity.UserTunnel;
import com.baomidou.mybatisplus.extension.service.IService;
public interface UserTunnelService extends IService<UserTunnel> {
R assignUserTunnel(UserTunnelDto userTunnelDto);
R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto);
R getUserTunnelList(UserTunnelQueryDto queryDto);
R removeUserTunnel(Integer id);
R updateUserTunnel(UserTunnelUpdateDto updateDto);
}
@@ -15,6 +15,7 @@ import com.admin.service.*;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
@@ -142,10 +143,26 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
Node updateNode = buildUpdateNode(nodeUpdateDto);
this.updateById(updateNode);
return R.ok();
}
Node updateNode = buildUpdateNode(nodeUpdateDto);
// Use LambdaUpdateWrapper to explicitly set nullable fields (serverIpV4/V6)
// because updateById() skips null fields by default
LambdaUpdateWrapper<Node> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(Node::getId, updateNode.getId())
.set(Node::getName, updateNode.getName())
.set(Node::getServerIp, updateNode.getServerIp())
.set(Node::getServerIpV4, updateNode.getServerIpV4())
.set(Node::getServerIpV6, updateNode.getServerIpV6())
.set(Node::getPort, updateNode.getPort())
.set(Node::getHttp, updateNode.getHttp())
.set(Node::getTls, updateNode.getTls())
.set(Node::getSocks, updateNode.getSocks())
.set(Node::getInterfaceName, updateNode.getInterfaceName())
.set(Node::getTcpListenAddr, updateNode.getTcpListenAddr())
.set(Node::getUdpListenAddr, updateNode.getUdpListenAddr())
.set(Node::getUpdatedTime, updateNode.getUpdatedTime());
this.update(wrapper);
return R.ok();
}
@Override
public R deleteNode(Long id) {
@@ -430,6 +430,15 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
if (hasNodeChanges && tunnelUpdateDto.getInNodeId() != null) {
List<ChainTunnel> backupChains = deepCopyChainTunnels(oldChainTunnels);
Set<Long> oldEntryNodeIds = oldChainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.map(ChainTunnel::getNodeId)
.collect(Collectors.toSet());
Set<Long> newEntryNodeIds = tunnelUpdateDto.getInNodeId().stream()
.map(ChainTunnel::getNodeId)
.collect(Collectors.toSet());
List<Long> nodeIds = new ArrayList<>();
Map<Long, Node> nodes = new HashMap<>();
@@ -511,6 +520,8 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
chainTunnelService.saveBatch(newChainTunnels);
syncForwardsForEntryNodeChanges(existingTunnel.getId(), oldEntryNodeIds, newEntryNodeIds);
} catch (Exception e) {
chainTunnelService.saveBatch(backupChains);
rebuildGostConfig(backupChains, existingTunnel);
@@ -525,7 +536,8 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
tunnel.setTrafficRatio(tunnelUpdateDto.getTrafficRatio());
tunnel.setInIp(tunnelUpdateDto.getInIp());
if (StringUtils.isEmpty(tunnel.getInIp())) {
boolean forceRegenerateInIp = hasNodeChanges && tunnelUpdateDto.getInNodeId() != null;
if (StringUtils.isEmpty(tunnel.getInIp()) || forceRegenerateInIp) {
java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
List<ChainTunnel> chainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)
@@ -597,7 +609,10 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Integer roleId = JwtUtil.getRoleIdFromToken();
Integer userId = JwtUtil.getUserIdFromToken();
if (roleId == 0) {
tunnelEntities = this.list(new QueryWrapper<Tunnel>().eq("status", 1));
tunnelEntities = this.list(new QueryWrapper<Tunnel>()
.eq("status", 1)
.orderByAsc("inx")
.orderByAsc("id"));
} else {
tunnelEntities = java.util.Collections.emptyList(); // 返回空列表
List<UserTunnel> userTunnels = userTunnelMapper.selectList(
@@ -609,7 +624,9 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
.collect(Collectors.toList());
tunnelEntities = this.list(new QueryWrapper<Tunnel>()
.in("id", tunnelIds)
.eq("status", 1));
.eq("status", 1)
.orderByAsc("inx")
.orderByAsc("id"));
}
}
@@ -1148,5 +1165,148 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
}
}
private void syncForwardsForEntryNodeChanges(Long tunnelId, Set<Long> oldEntryNodeIds, Set<Long> newEntryNodeIds) {
Set<Long> addedNodeIds = new HashSet<>(newEntryNodeIds);
addedNodeIds.removeAll(oldEntryNodeIds);
Set<Long> removedNodeIds = new HashSet<>(oldEntryNodeIds);
removedNodeIds.removeAll(newEntryNodeIds);
if (addedNodeIds.isEmpty() && removedNodeIds.isEmpty()) {
return;
}
List<Forward> forwards = forwardService.list(
new QueryWrapper<Forward>().eq("tunnel_id", tunnelId.intValue())
);
if (forwards.isEmpty()) {
return;
}
Tunnel tunnel = this.getById(tunnelId);
if (tunnel == null) {
return;
}
for (Forward forward : forwards) {
if (forward.getStatus() != 1) {
continue;
}
UserTunnel userTunnel = userTunnelService.getOne(
new QueryWrapper<UserTunnel>()
.eq("user_id", forward.getUserId())
.eq("tunnel_id", tunnelId.intValue())
);
for (Long removedNodeId : removedNodeIds) {
ForwardPort forwardPort = forwardPortService.getOne(
new QueryWrapper<ForwardPort>()
.eq("forward_id", forward.getId())
.eq("node_id", removedNodeId)
);
if (forwardPort != null) {
String serviceName = buildForwardServiceName(forward.getId(), forward.getUserId(), userTunnel);
JSONArray services = new JSONArray();
services.add(serviceName + "_tcp");
services.add(serviceName + "_udp");
GostUtil.DeleteService(removedNodeId, services);
forwardPortService.removeById(forwardPort.getId());
}
}
for (Long addedNodeId : addedNodeIds) {
ForwardPort existingPort = forwardPortService.getOne(
new QueryWrapper<ForwardPort>()
.eq("forward_id", forward.getId())
.eq("node_id", addedNodeId)
);
if (existingPort != null) {
continue;
}
List<ForwardPort> existingPorts = forwardPortService.list(
new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId())
);
Integer targetPort = null;
if (!existingPorts.isEmpty()) {
targetPort = existingPorts.get(0).getPort();
}
Integer allocatedPort = allocatePortForNode(addedNodeId, targetPort, forward.getId());
if (allocatedPort == null) {
System.err.println("Failed to allocate port on node " + addedNodeId + " for forward " + forward.getId());
continue;
}
ForwardPort newForwardPort = new ForwardPort();
newForwardPort.setForwardId(forward.getId());
newForwardPort.setNodeId(addedNodeId);
newForwardPort.setPort(allocatedPort);
forwardPortService.save(newForwardPort);
Node node = nodeService.getById(addedNodeId);
if (node != null) {
String serviceName = buildForwardServiceName(forward.getId(), forward.getUserId(), userTunnel);
Integer limiter = (userTunnel != null && userTunnel.getSpeedId() != null) ? userTunnel.getSpeedId() : null;
GostUtil.AddAndUpdateService(serviceName, limiter, node, forward, newForwardPort, tunnel, "AddService");
}
}
}
}
private Integer allocatePortForNode(Long nodeId, Integer preferredPort, Long forwardId) {
Node node = nodeService.getById(nodeId);
if (node == null || node.getPort() == null) {
return null;
}
Set<Integer> usedPorts = new HashSet<>();
List<ChainTunnel> chainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("node_id", nodeId)
);
for (ChainTunnel ct : chainTunnels) {
if (ct.getPort() != null) {
usedPorts.add(ct.getPort());
}
}
List<ForwardPort> forwardPorts = forwardPortService.list(
new QueryWrapper<ForwardPort>()
.eq("node_id", nodeId)
.ne("forward_id", forwardId)
);
for (ForwardPort fp : forwardPorts) {
if (fp.getPort() != null) {
usedPorts.add(fp.getPort());
}
}
List<Integer> availablePorts = parsePorts(node.getPort());
if (preferredPort != null && availablePorts.contains(preferredPort) && !usedPorts.contains(preferredPort)) {
return preferredPort;
}
for (Integer port : availablePorts) {
if (!usedPorts.contains(port)) {
return port;
}
}
return null;
}
private String buildForwardServiceName(Long forwardId, Integer userId, UserTunnel userTunnel) {
int userTunnelId = (userTunnel != null) ? userTunnel.getId() : 0;
return forwardId + "_" + userId + "_" + userTunnelId;
}
}
@@ -1,120 +1,181 @@
package com.admin.service.impl;
import com.admin.common.dto.*;
import com.admin.common.lang.R;
import com.admin.entity.UserTunnel;
import com.admin.mapper.TunnelMapper;
import com.admin.mapper.UserTunnelMapper;
import com.admin.service.TunnelService;
import com.admin.service.UserTunnelService;
import com.admin.service.ForwardService;
import com.admin.service.NodeService;
import com.admin.common.utils.GostUtil;
import com.admin.entity.Forward;
import com.admin.entity.Tunnel;
import com.admin.entity.Node;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
/**
* <p>
* 用户隧道权限服务实现类
* 提供用户隧道权限的分配、查询、更新和删除功能
* 支持流量限制、数量限制、过期时间和限速规则的管理
* </p>
*
* @author QAQ
* @since 2025-06-03
*/
@Service
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
@Resource
@Lazy
private ForwardService forwardService;
@Override
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
int count = this.count(new QueryWrapper<UserTunnel>().eq("user_id", userTunnelDto.getUserId()).eq("tunnel_id", userTunnelDto.getTunnelId()));
if (count > 0) return R.err("该用户已拥有此隧道权限");
UserTunnel userTunnel = new UserTunnel();
BeanUtils.copyProperties(userTunnelDto, userTunnel);
userTunnel.setStatus(1);
this.save(userTunnel);
return R.ok();
}
@Override
public R getUserTunnelList(UserTunnelQueryDto queryDto) {
List<UserTunnelWithDetailDto> userTunnelWithDetails = this.baseMapper.getUserTunnelWithDetails(queryDto.getUserId());
return R.ok(userTunnelWithDetails);
}
@Override
public R removeUserTunnel(Integer id) {
UserTunnel userTunnel = this.getById(id);
if (userTunnel == null) return R.err("未找到对应的用户隧道权限记录");
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", userTunnel.getUserId()).eq("tunnel_id", userTunnel.getTunnelId()));
for (Forward forward : forwardList) {
forwardService.deleteForward(forward.getId());
}
this.removeById(id);
return R.ok();
}
@Override
public R updateUserTunnel(UserTunnelUpdateDto updateDto) {
UserTunnel userTunnel = this.getById(updateDto.getId());
if (userTunnel == null) return R.err("隧道不存在");
boolean speedChanged = hasSpeedChanged(userTunnel.getSpeedId(), updateDto.getSpeedId());
userTunnel.setFlow(updateDto.getFlow());
userTunnel.setNum(updateDto.getNum());
updateOptionalProperty(userTunnel::setFlowResetTime, updateDto.getFlowResetTime());
updateOptionalProperty(userTunnel::setExpTime, updateDto.getExpTime());
updateOptionalProperty(userTunnel::setStatus, updateDto.getStatus());
userTunnel.setSpeedId(updateDto.getSpeedId());
this.updateById(userTunnel);
if (speedChanged) {
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", userTunnel.getUserId()).eq("tunnel_id", userTunnel.getTunnelId()));
for (Forward forward : forwardList) {
ForwardUpdateDto forwardUpdateDto = new ForwardUpdateDto();
forwardUpdateDto.setId(forward.getId());
forwardUpdateDto.setUserId(forward.getUserId());
forwardUpdateDto.setName(forward.getName());
forwardUpdateDto.setRemoteAddr(forward.getRemoteAddr());
forwardUpdateDto.setStrategy(forward.getStrategy());
forwardService.updateForward(forwardUpdateDto);
}
}
return R.err("用户隧道权限更新失败");
}
private <T> void updateOptionalProperty(java.util.function.Consumer<T> setter, T value) {
if (value != null) {
setter.accept(value);
}
}
private boolean hasSpeedChanged(Integer oldSpeedId, Integer newSpeedId) {
if (oldSpeedId == null && newSpeedId == null) {
return false;
}
if (oldSpeedId == null || newSpeedId == null) {
return true;
}
return !oldSpeedId.equals(newSpeedId);
}
}
package com.admin.service.impl;
import com.admin.common.dto.*;
import com.admin.common.lang.R;
import com.admin.entity.User;
import com.admin.entity.UserTunnel;
import com.admin.mapper.UserTunnelMapper;
import com.admin.service.UserService;
import com.admin.service.UserTunnelService;
import com.admin.service.ForwardService;
import com.admin.entity.Forward;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
@Resource
@Lazy
private ForwardService forwardService;
@Resource
@Lazy
private UserService userService;
@Override
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
int count = this.count(new QueryWrapper<UserTunnel>()
.eq("user_id", userTunnelDto.getUserId())
.eq("tunnel_id", userTunnelDto.getTunnelId()));
if (count > 0) return R.err("该用户已拥有此隧道权限");
User user = userService.getById(userTunnelDto.getUserId());
if (user == null) return R.err("用户不存在");
UserTunnel userTunnel = new UserTunnel();
userTunnel.setUserId(userTunnelDto.getUserId());
userTunnel.setTunnelId(userTunnelDto.getTunnelId());
userTunnel.setSpeedId(userTunnelDto.getSpeedId());
userTunnel.setStatus(1);
userTunnel.setInFlow(0L);
userTunnel.setOutFlow(0L);
userTunnel.setFlow(userTunnelDto.getFlow() != null ? userTunnelDto.getFlow() : user.getFlow());
userTunnel.setNum(userTunnelDto.getNum() != null ? userTunnelDto.getNum() : user.getNum());
userTunnel.setFlowResetTime(userTunnelDto.getFlowResetTime() != null ? userTunnelDto.getFlowResetTime() : user.getFlowResetTime());
userTunnel.setExpTime(userTunnelDto.getExpTime() != null ? userTunnelDto.getExpTime() : user.getExpTime());
this.save(userTunnel);
return R.ok();
}
@Override
public R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto) {
User user = userService.getById(batchAssignDto.getUserId());
if (user == null) return R.err("用户不存在");
Map<Integer, UserTunnelBatchAssignDto.TunnelAssignItem> uniqueTunnels = new LinkedHashMap<>();
for (UserTunnelBatchAssignDto.TunnelAssignItem item : batchAssignDto.getTunnels()) {
uniqueTunnels.putIfAbsent(item.getTunnelId(), item);
}
Set<Integer> existingTunnelIds = this.list(
new QueryWrapper<UserTunnel>()
.eq("user_id", batchAssignDto.getUserId())
.in("tunnel_id", uniqueTunnels.keySet())
).stream().map(UserTunnel::getTunnelId).collect(Collectors.toSet());
List<UserTunnel> toSave = new ArrayList<>();
List<Integer> skippedIds = new ArrayList<>();
for (UserTunnelBatchAssignDto.TunnelAssignItem item : uniqueTunnels.values()) {
if (existingTunnelIds.contains(item.getTunnelId())) {
skippedIds.add(item.getTunnelId());
continue;
}
UserTunnel ut = new UserTunnel();
ut.setUserId(batchAssignDto.getUserId());
ut.setTunnelId(item.getTunnelId());
ut.setSpeedId(item.getSpeedId());
ut.setStatus(1);
ut.setInFlow(0L);
ut.setOutFlow(0L);
ut.setFlow(user.getFlow());
ut.setNum(user.getNum());
ut.setFlowResetTime(user.getFlowResetTime());
ut.setExpTime(user.getExpTime());
toSave.add(ut);
}
if (toSave.isEmpty()) {
return R.err("所选隧道用户均已拥有权限");
}
this.saveBatch(toSave);
if (!skippedIds.isEmpty()) {
return R.ok("成功分配 " + toSave.size() + " 个隧道,跳过 " + skippedIds.size() + " 个已有权限的隧道");
}
return R.ok();
}
@Override
public R getUserTunnelList(UserTunnelQueryDto queryDto) {
List<UserTunnelWithDetailDto> userTunnelWithDetails = this.baseMapper.getUserTunnelWithDetails(queryDto.getUserId());
return R.ok(userTunnelWithDetails);
}
@Override
public R removeUserTunnel(Integer id) {
UserTunnel userTunnel = this.getById(id);
if (userTunnel == null) return R.err("未找到对应的用户隧道权限记录");
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>()
.eq("user_id", userTunnel.getUserId())
.eq("tunnel_id", userTunnel.getTunnelId()));
for (Forward forward : forwardList) {
forwardService.deleteForward(forward.getId());
}
this.removeById(id);
return R.ok();
}
@Override
public R updateUserTunnel(UserTunnelUpdateDto updateDto) {
UserTunnel userTunnel = this.getById(updateDto.getId());
if (userTunnel == null) return R.err("隧道不存在");
boolean speedChanged = hasSpeedChanged(userTunnel.getSpeedId(), updateDto.getSpeedId());
userTunnel.setFlow(updateDto.getFlow());
userTunnel.setNum(updateDto.getNum());
updateOptionalProperty(userTunnel::setFlowResetTime, updateDto.getFlowResetTime());
updateOptionalProperty(userTunnel::setExpTime, updateDto.getExpTime());
updateOptionalProperty(userTunnel::setStatus, updateDto.getStatus());
userTunnel.setSpeedId(updateDto.getSpeedId());
this.updateById(userTunnel);
if (speedChanged) {
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>()
.eq("user_id", userTunnel.getUserId())
.eq("tunnel_id", userTunnel.getTunnelId()));
for (Forward forward : forwardList) {
ForwardUpdateDto forwardUpdateDto = new ForwardUpdateDto();
forwardUpdateDto.setId(forward.getId());
forwardUpdateDto.setUserId(forward.getUserId());
forwardUpdateDto.setName(forward.getName());
forwardUpdateDto.setRemoteAddr(forward.getRemoteAddr());
forwardUpdateDto.setStrategy(forward.getStrategy());
forwardService.updateForward(forwardUpdateDto);
}
}
return R.ok();
}
private <T> void updateOptionalProperty(java.util.function.Consumer<T> setter, T value) {
if (value != null) {
setter.accept(value);
}
}
private boolean hasSpeedChanged(Integer oldSpeedId, Integer newSpeedId) {
if (oldSpeedId == null && newSpeedId == null) {
return false;
}
if (oldSpeedId == null || newSpeedId == null) {
return true;
}
return !oldSpeedId.equals(newSpeedId);
}
}
+16 -14
View File
@@ -1,34 +1,36 @@
# VITE FRONTEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Web management console for Flux Panel.
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI (NextUI).
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI.
## STRUCTURE
```
vite-frontend/
├── src/
│ ├── pages/ # Route views
│ ├── pages/ # Route views (some very large single-file pages)
│ ├── components/ # Reusable UI parts
│ ├── layouts/ # Page wrappers
│ ├── api/ # Axios wrappers
│ ├── config/ # App settings
│ └── utils/ # Helpers
├── vite.config.ts # Vite config (Base: '/')
│ ├── layouts/ # Admin vs H5 layouts
│ ├── api/ # API functions + axios wrapper
│ ├── config/ # Site config (title, repo, version)
│ └── utils/ # Auth/JWT + WebView helpers
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
├── eslint.config.mjs # ESLint 9 flat config
└── package.json
```
## CONVENTIONS
- **UI Lib**: HeroUI (formerly NextUI) + Tailwind CSS 4.
- **Routing**: React Router DOM 6.
- **State**: Check `provider.tsx` or local state.
- **Build**: Output to `dist/`.
- **Routing**: React Router v6 routes in `vite-frontend/src/App.tsx`.
- **Auth**: JWT stored as `localStorage.token`; sent as `Authorization` header (no prefix) in `vite-frontend/src/api/network.ts`.
- **Base URL**: Defaults to `/api/v1/` (or `VITE_API_BASE`); WebView mode selects a panel address via `vite-frontend/src/utils/panel.ts`.
- **UI**: HeroUI provider + theme + toast wired in `vite-frontend/src/provider.tsx`.
## COMMANDS
```bash
# Dev
cd vite-frontend
npm run dev
# Build
npm run build
npm run lint
```
+38
View File
@@ -0,0 +1,38 @@
# VITE FRONTEND (src) KNOWLEDGE BASE
## OVERVIEW
React app entry + routing + providers. This is where UI architecture decisions live.
## STRUCTURE
```
vite-frontend/src/
├── main.tsx # ReactDOM + BrowserRouter + Provider
├── provider.tsx # HeroUI + theme + toaster + i18n wrapper
├── App.tsx # Routes + ProtectedRoute + H5 layout selection
├── api/ # Axios wrapper + typed endpoint helpers
├── pages/ # Route views (large)
├── layouts/ # Admin/H5 page chrome
├── components/ # Shared UI components
├── utils/ # JWT parsing + auth helpers + WebView utilities
└── styles/ # globals.css
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Route definitions | `vite-frontend/src/App.tsx` | React Router v6 |
| API client baseURL | `vite-frontend/src/api/network.ts` | `/api/v1/` + token header |
| Token decoding | `vite-frontend/src/utils/jwt.ts` | Checks `exp` vs now |
| Role checks | `vite-frontend/src/utils/auth.ts` | `isAdmin()` is `role_id == 0` |
| WebView integration | `vite-frontend/src/api/network.ts` | Panel address selection in WebView mode |
## CONVENTIONS
- Token is stored in `localStorage.token` and sent as `Authorization` header (raw token string).
- H5 mode detection is in `vite-frontend/src/App.tsx` (screen/user-agent/query param `h5=true`).
## COMMANDS
```bash
cd vite-frontend
npm run dev
npm run lint
```
+16
View File
@@ -0,0 +1,16 @@
# VITE FRONTEND (src/api) KNOWLEDGE BASE
## OVERVIEW
API client layer. Wraps axios and normalizes backend responses (`{ code, msg, data }`).
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Axios wrapper | `vite-frontend/src/api/network.ts` | Sets `axios.defaults.baseURL`; adds `Authorization` header |
| BaseURL init (WebView vs web) | `vite-frontend/src/api/network.ts` | WebView mode calls `getPanelAddresses()` |
| Endpoint functions | `vite-frontend/src/api/index.ts` | Mostly `Network.post("/…")` |
## CONVENTIONS
- Default baseURL is `/api/v1/` (or `${VITE_API_BASE}/api/v1/`).
- In WebView mode, baseURL is derived from the selected panel address; if unset, requests return `code: -1` with a “set panel address” message.
- 401 responses clear localStorage and redirect to `/`.
+2
View File
@@ -46,6 +46,8 @@ export const updateTunnelOrder = (data: { tunnels: Array<{ id: number; inx: numb
// 用户隧道权限管理操作 - 全部使用POST请求
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
export const batchAssignUserTunnel = (data: { userId: number; tunnels: Array<{ tunnelId: number; speedId?: number | null }> }) =>
Network.post("/tunnel/user/batch-assign", data);
export const getUserTunnelList = (queryData: any = {}) => Network.post("/tunnel/user/list", queryData);
export const removeUserTunnel = (params: any) => Network.post("/tunnel/user/remove", params);
export const updateUserTunnel = (data: any) => Network.post("/tunnel/user/update", data);
+30
View File
@@ -0,0 +1,30 @@
# VITE FRONTEND (pages) KNOWLEDGE BASE
## OVERVIEW
Route views rendered by `vite-frontend/src/App.tsx`. Several pages are large, single-file screens.
## STRUCTURE
```
vite-frontend/src/pages/
├── index.tsx # Login + captcha flow
├── dashboard.tsx
├── forward.tsx # Large
├── tunnel.tsx # Large
├── node.tsx # Large
├── user.tsx # Large
├── config.tsx
├── limit.tsx
├── profile.tsx
├── settings.tsx
└── change-password.tsx
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Login flow | `vite-frontend/src/pages/index.tsx` | Calls `login()` and stores `localStorage.token` |
| API calls | `vite-frontend/src/api/index.ts` | Thin wrappers around `Network.post` |
| Token expiration behavior | `vite-frontend/src/api/network.ts` | Clears localStorage + redirects on 401 |
## CONVENTIONS
- Pages call API wrappers from `vite-frontend/src/api/index.ts` (most endpoints are POST).
+104 -141
View File
@@ -30,7 +30,7 @@ import {
User,
UserForm,
UserTunnel,
UserTunnelForm,
TunnelAssignItem,
Tunnel,
SpeedLimit,
Pagination as PaginationType
@@ -41,7 +41,7 @@ import {
updateUser,
deleteUser,
getTunnelList,
assignUserTunnel,
batchAssignUserTunnel,
getUserTunnelList,
removeUserTunnel,
updateUserTunnel,
@@ -134,15 +134,8 @@ export default function UserPage() {
const [tunnelListLoading, setTunnelListLoading] = useState(false);
// 分配新隧道权限相关状态
const [tunnelForm, setTunnelForm] = useState<UserTunnelForm>({
tunnelId: null,
flow: 100,
num: 10,
expTime: null,
flowResetTime: 0,
speedId: null
});
const [assignLoading, setAssignLoading] = useState(false);
const [batchTunnelSelections, setBatchTunnelSelections] = useState<Map<number, number | null>>(new Map());
// 编辑隧道权限相关状态
const { isOpen: isEditTunnelModalOpen, onOpen: onEditTunnelModalOpen, onClose: onEditTunnelModalClose } = useDisclosure();
@@ -334,46 +327,31 @@ export default function UserPage() {
// 隧道权限管理操作
const handleManageTunnels = (user: User) => {
setCurrentUser(user);
setTunnelForm({
tunnelId: null,
flow: 100,
num: 10,
expTime: null,
flowResetTime: 0,
speedId: null
});
setBatchTunnelSelections(new Map());
onTunnelModalOpen();
loadUserTunnels(user.id);
};
const handleAssignTunnel = async () => {
if (!tunnelForm.tunnelId || !tunnelForm.expTime || !currentUser) {
toast.error('请填写完整信息');
const handleBatchAssignTunnel = async () => {
if (batchTunnelSelections.size === 0 || !currentUser) {
toast.error('请选择至少一个隧道');
return;
}
setAssignLoading(true);
try {
const response = await assignUserTunnel({
const tunnelsToAssign: TunnelAssignItem[] = Array.from(batchTunnelSelections.entries()).map(
([tunnelId, speedId]) => ({ tunnelId, speedId })
);
const response = await batchAssignUserTunnel({
userId: currentUser.id,
tunnelId: tunnelForm.tunnelId,
flow: tunnelForm.flow,
num: tunnelForm.num,
expTime: tunnelForm.expTime.getTime(),
flowResetTime: tunnelForm.flowResetTime,
speedId: tunnelForm.speedId
tunnels: tunnelsToAssign
});
if (response.code === 0) {
toast.success('分配成功');
setTunnelForm({
tunnelId: null,
flow: 100,
num: 10,
expTime: null,
flowResetTime: 0,
speedId: null
});
toast.success(response.msg || '分配成功');
setBatchTunnelSelections(new Map());
loadUserTunnels(currentUser.id);
} else {
toast.error(response.msg || '分配失败');
@@ -513,19 +491,38 @@ export default function UserPage() {
}
};
// 过滤数据
const availableTunnels = tunnels.filter(
tunnel => !userTunnels.some(ut => ut.tunnelId === tunnel.id)
);
const availableSpeedLimits = speedLimits.filter(
speedLimit => speedLimit.tunnelId === tunnelForm.tunnelId
);
const editAvailableSpeedLimits = speedLimits.filter(
speedLimit => speedLimit.tunnelId === editTunnelForm?.tunnelId
);
const getSpeedLimitsForTunnel = (tunnelId: number) => {
return speedLimits.filter(sl => sl.tunnelId === tunnelId);
};
const toggleTunnelSelection = (tunnelId: number) => {
setBatchTunnelSelections(prev => {
const newMap = new Map(prev);
if (newMap.has(tunnelId)) {
newMap.delete(tunnelId);
} else {
newMap.set(tunnelId, null);
}
return newMap;
});
};
const updateTunnelSpeedLimit = (tunnelId: number, speedId: number | null) => {
setBatchTunnelSelections(prev => {
const newMap = new Map(prev);
newMap.set(tunnelId, speedId);
return newMap;
});
};
const isTunnelAssigned = (tunnelId: number) => {
return userTunnels.some(ut => ut.tunnelId === tunnelId);
};
return (
<div className="px-3 lg:px-6 py-8">
@@ -878,107 +875,73 @@ export default function UserPage() {
<div>
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="选择隧道"
selectedKeys={tunnelForm.tunnelId ? [tunnelForm.tunnelId.toString()] : []}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as string;
setTunnelForm(prev => ({ ...prev, tunnelId: Number(value) || null, speedId: null }));
}}
>
{availableTunnels.map(tunnel => (
<SelectItem key={tunnel.id.toString()} textValue={tunnel.name}>
{tunnel.name}
</SelectItem>
))}
</Select>
<Select
label="限速规则"
selectedKeys={tunnelForm.speedId ? [tunnelForm.speedId.toString()] : ["null"]}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as string;
setTunnelForm(prev => ({ ...prev, speedId: value === "null" ? null : Number(value) }));
}}
isDisabled={!tunnelForm.tunnelId}
>
{[
<SelectItem key="null" textValue="不限速">不限速</SelectItem>,
...availableSpeedLimits.map(speedLimit => (
<SelectItem key={speedLimit.id.toString()} textValue={speedLimit.name}>
{speedLimit.name}
</SelectItem>
))
]}
</Select>
<Input
label="流量限制(GB)"
type="number"
value={tunnelForm.flow.toString()}
onChange={(e) => {
const value = Math.min(Math.max(Number(e.target.value) || 0, 1), 99999);
setTunnelForm(prev => ({ ...prev, flow: value }));
}}
min="1"
max="99999"
/>
<Input
label="转发数量"
type="number"
value={tunnelForm.num.toString()}
onChange={(e) => {
const value = Math.min(Math.max(Number(e.target.value) || 0, 1), 99999);
setTunnelForm(prev => ({ ...prev, num: value }));
}}
min="1"
max="99999"
/>
<Select
label="流量重置日期"
selectedKeys={[tunnelForm.flowResetTime.toString()]}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as string;
setTunnelForm(prev => ({ ...prev, flowResetTime: Number(value) }));
}}
>
<>
<SelectItem key="0" textValue="不重置">
不重置
</SelectItem>
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<SelectItem key={day.toString()} textValue={`每月${day}号(0点重置)`}>
每月{day}号(0点重置)
</SelectItem>
))}
</>
</Select>
<DatePicker
label="到期时间"
value={tunnelForm.expTime ? parseDate(tunnelForm.expTime.toISOString().split('T')[0]) as any : null}
onChange={(date) => {
if (date) {
const jsDate = new Date(date.year, date.month - 1, date.day, 23, 59, 59);
setTunnelForm(prev => ({ ...prev, expTime: jsDate }));
} else {
setTunnelForm(prev => ({ ...prev, expTime: null }));
}
}}
showMonthAndYearPickers
className="cursor-pointer"
/>
<div className="text-sm text-gray-500 bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
流量限制、转发数量、到期时间、流量重置时间将自动继承用户设置
</div>
<div className="border rounded-lg divide-y dark:divide-gray-700 max-h-64 overflow-y-auto">
{tunnels.map(tunnel => {
const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(tunnel.id);
const tunnelSpeedLimits = getSpeedLimitsForTunnel(tunnel.id);
return (
<div
key={tunnel.id}
className={`p-3 flex items-center justify-between gap-4 ${isAssigned ? 'bg-gray-100 dark:bg-gray-800' : ''}`}
>
<div className="flex items-center gap-3 flex-1">
<input
type="checkbox"
checked={isSelected}
disabled={isAssigned}
onChange={() => toggleTunnelSelection(tunnel.id)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-50"
/>
<span className={isAssigned ? 'text-gray-400' : ''}>
{tunnel.name}
</span>
{isAssigned && (
<Chip size="sm" variant="flat" color="default">已分配</Chip>
)}
</div>
{isSelected && !isAssigned && (
<Select
size="sm"
label="限速规则"
className="w-40"
selectedKeys={batchTunnelSelections.get(tunnel.id) !== null && batchTunnelSelections.get(tunnel.id) !== undefined ? [batchTunnelSelections.get(tunnel.id)!.toString()] : ["null"]}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as string;
updateTunnelSpeedLimit(tunnel.id, value === "null" ? null : Number(value));
}}
>
{[
<SelectItem key="null" textValue="不限速">不限速</SelectItem>,
...tunnelSpeedLimits.map(sl => (
<SelectItem key={sl.id.toString()} textValue={sl.name}>
{sl.name}
</SelectItem>
))
]}
</Select>
)}
</div>
);
})}
{tunnels.length === 0 && (
<div className="p-4 text-center text-gray-500">暂无可用隧道</div>
)}
</div>
<Button
color="primary"
onPress={handleAssignTunnel}
onPress={handleBatchAssignTunnel}
isLoading={assignLoading}
isDisabled={batchTunnelSelections.size === 0}
>
分配权限
分配权限 {batchTunnelSelections.size > 0 && `(${batchTunnelSelections.size}个隧道)`}
</Button>
</div>
</div>
+9
View File
@@ -58,6 +58,15 @@ export interface UserTunnelForm {
speedId: number | null;
}
export interface TunnelAssignItem {
tunnelId: number;
speedId: number | null;
}
export interface UserTunnelBatchAssignForm {
tunnels: TunnelAssignItem[];
}
export interface Tunnel {
id: number;
name: string;