mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 02:36:37 +08:00
Merge branch 'main' into opencode/lucky-otter
This commit is contained in:
@@ -2,35 +2,7 @@
|
|||||||
|
|
||||||
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
||||||
|
|
||||||
## Original Project
|
|
||||||
- **Name**: flux-panel
|
|
||||||
- **Source**: https://github.com/bqlpfy/flux-panel
|
|
||||||
- **License**: Apache License 2.0
|
|
||||||
|
|
||||||
## Modifications
|
|
||||||
The following major changes and additions have been made in this fork (FLVX):
|
|
||||||
|
|
||||||
### 1. Backend Architecture (Replaced)
|
|
||||||
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
|
|
||||||
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
|
|
||||||
|
|
||||||
### 2. Forwarding Agent (Modified)
|
|
||||||
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
|
|
||||||
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
|
|
||||||
|
|
||||||
### 3. Frontend (Modified)
|
|
||||||
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
|
|
||||||
|
|
||||||
### 4. Mobile Applications (Removed)
|
|
||||||
- **Removed**: `android-app/` - Source code for the Android client.
|
|
||||||
- **Removed**: `ios-app/` - Source code for the iOS client.
|
|
||||||
|
|
||||||
### 5. Infrastructure & Scripts
|
|
||||||
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
|
|
||||||
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
|
|
||||||
- **Added**: `AGENTS.md` (Project documentation).
|
|
||||||
|
|
||||||
---
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
- 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制
|
- 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制
|
||||||
@@ -39,6 +11,10 @@ The following major changes and additions have been made in this fork (FLVX):
|
|||||||
- 可针对 **指定用户的指定隧道进行限速** 设置
|
- 可针对 **指定用户的指定隧道进行限速** 设置
|
||||||
- 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型
|
- 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型
|
||||||
- 提供灵活的转发策略配置,适用于多种网络场景
|
- 提供灵活的转发策略配置,适用于多种网络场景
|
||||||
|
- 面板分享,支持将节点分享给其他人,面板对接面板
|
||||||
|
- 支持分组权限管理,隧道分组、用户分组
|
||||||
|
- 支持批量功能,可以批量下发配置,启停等
|
||||||
|
- 支持隧道修改配置、转发修改隧道
|
||||||
|
|
||||||
|
|
||||||
## 部署流程
|
## 部署流程
|
||||||
@@ -73,6 +49,37 @@ curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.
|
|||||||
|
|
||||||
> ⚠️ 首次登录后请立即修改默认密码!
|
> ⚠️ 首次登录后请立即修改默认密码!
|
||||||
|
|
||||||
|
---
|
||||||
|
## Original Project
|
||||||
|
- **Name**: flux-panel
|
||||||
|
- **Source**: https://github.com/bqlpfy/flux-panel
|
||||||
|
- **License**: Apache License 2.0
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
The following major changes and additions have been made in this fork (FLVX):
|
||||||
|
|
||||||
|
### 1. Backend Architecture (Replaced)
|
||||||
|
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
|
||||||
|
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
|
||||||
|
|
||||||
|
### 2. Forwarding Agent (Modified)
|
||||||
|
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
|
||||||
|
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
|
||||||
|
|
||||||
|
### 3. Frontend (Modified)
|
||||||
|
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
|
||||||
|
|
||||||
|
### 4. Mobile Applications (Removed)
|
||||||
|
- **Removed**: `android-app/` - Source code for the Android client.
|
||||||
|
- **Removed**: `ios-app/` - Source code for the iOS client.
|
||||||
|
|
||||||
|
### 5. Infrastructure & Scripts
|
||||||
|
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
|
||||||
|
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
|
||||||
|
- **Added**: `AGENTS.md` (Project documentation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,57 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
|
|||||||
安装完成后,服务会自动启动。
|
安装完成后,服务会自动启动。
|
||||||
- 查看状态: `systemctl status flux_agent`
|
- 查看状态: `systemctl status flux_agent`
|
||||||
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
|
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Caddy 反向代理(可选)
|
||||||
|
|
||||||
|
如果需要通过域名访问面板并自动获取 HTTPS 证书,可以使用 Caddy 作为反向代理。
|
||||||
|
|
||||||
|
### 1. 安装 Caddy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian / Ubuntu
|
||||||
|
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||||
|
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||||
|
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
其他系统请参考 [Caddy 官方安装文档](https://caddyserver.com/docs/install)。
|
||||||
|
|
||||||
|
### 2. 配置 Caddyfile
|
||||||
|
|
||||||
|
编辑 Caddy 配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/caddy/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 面板域名配置
|
||||||
|
|
||||||
|
将 `panel.example.com` 替换为你自己的域名:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
panel.example.com {
|
||||||
|
reverse_proxy localhost:6366
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy 会自动为域名申请和续期 HTTPS 证书,无需额外配置。
|
||||||
|
|
||||||
|
### 3. 重启 Caddy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 注意事项
|
||||||
|
|
||||||
|
- 确保域名已正确解析到服务器 IP。
|
||||||
|
- 确保服务器防火墙放行了 **80** 和 **443** 端口(Caddy 自动申请证书需要)。
|
||||||
|
- 使用 Caddy 反向代理后,可以在 `.env` 中将前端端口改为仅监听本地,避免直接暴露:
|
||||||
|
```
|
||||||
|
FRONTEND_PORT=127.0.0.1:6366
|
||||||
|
```
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ func NewFederationClient() *FederationClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewFederationClientWithTimeout(timeout time.Duration) *FederationClient {
|
||||||
|
return &FederationClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
|
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
|
||||||
url = strings.TrimSuffix(url, "/")
|
url = strings.TrimSuffix(url, "/")
|
||||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
|
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-backend/internal/http/client"
|
"go-backend/internal/http/client"
|
||||||
@@ -40,6 +41,17 @@ type resetPeerShareFlowRequest struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updatePeerShareRequest struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||||
|
ExpiryTime int64 `json:"expiryTime"`
|
||||||
|
PortRangeStart int `json:"portRangeStart"`
|
||||||
|
PortRangeEnd int `json:"portRangeEnd"`
|
||||||
|
AllowedDomains string `json:"allowedDomains"`
|
||||||
|
AllowedIPs string `json:"allowedIps"`
|
||||||
|
}
|
||||||
|
|
||||||
type nodeImportRequest struct {
|
type nodeImportRequest struct {
|
||||||
RemoteURL string `json:"remoteUrl"`
|
RemoteURL string `json:"remoteUrl"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@@ -325,6 +337,80 @@ func (h *Handler) federationShareResetFlow(w http.ResponseWriter, r *http.Reques
|
|||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) federationShareUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updatePeerShareRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ID <= 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Share ID is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := h.repo.GetPeerShare(req.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if share == nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Share not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.MaxBandwidth < 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Max bandwidth cannot be negative"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ExpiryTime < 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Expiry time cannot be negative"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PortRangeStart < 0 || req.PortRangeStart > 65535 || req.PortRangeEnd < 0 || req.PortRangeEnd > 65535 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid port range"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PortRangeStart > req.PortRangeEnd {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Port range start cannot be greater than end"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedIPs, err := normalizePeerShareAllowedIPs(req.AllowedIPs)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
share.Name = req.Name
|
||||||
|
share.MaxBandwidth = req.MaxBandwidth
|
||||||
|
share.ExpiryTime = req.ExpiryTime
|
||||||
|
share.PortRangeStart = req.PortRangeStart
|
||||||
|
share.PortRangeEnd = req.PortRangeEnd
|
||||||
|
share.AllowedDomains = req.AllowedDomains
|
||||||
|
share.AllowedIPs = allowedIPs
|
||||||
|
share.UpdatedTime = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if err := h.repo.UpdatePeerShare(share); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
@@ -1317,6 +1403,74 @@ func isPeerIPAllowed(clientIP net.IP, whitelist string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) syncRemoteNodeStatuses(items []map[string]interface{}) {
|
||||||
|
type remoteEntry struct {
|
||||||
|
index int
|
||||||
|
remoteURL string
|
||||||
|
remoteToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
var remotes []remoteEntry
|
||||||
|
for i, item := range items {
|
||||||
|
isRemote, _ := item["isRemote"].(int)
|
||||||
|
if isRemote != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url, _ := item["remoteUrl"].(string)
|
||||||
|
token, _ := item["remoteToken"].(string)
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if url == "" || token == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remotes = append(remotes, remoteEntry{index: i, remoteURL: url, remoteToken: token})
|
||||||
|
}
|
||||||
|
if len(remotes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localDomain := h.federationLocalDomain()
|
||||||
|
fc := client.NewFederationClientWithTimeout(5 * time.Second)
|
||||||
|
|
||||||
|
type syncResult struct {
|
||||||
|
index int
|
||||||
|
status int
|
||||||
|
syncError string
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]syncResult, len(remotes))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, entry := range remotes {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, e remoteEntry) {
|
||||||
|
defer wg.Done()
|
||||||
|
info, err := fc.Connect(e.remoteURL, e.remoteToken, localDomain)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Invalid token") || strings.Contains(errMsg, "Unauthorized") {
|
||||||
|
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_deleted"}
|
||||||
|
} else if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "Share is disabled") {
|
||||||
|
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_disabled"}
|
||||||
|
} else if strings.Contains(errMsg, "Share expired") {
|
||||||
|
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_expired"}
|
||||||
|
} else {
|
||||||
|
results[idx] = syncResult{index: e.index, status: 0, syncError: errMsg}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results[idx] = syncResult{index: e.index, status: info.Status, syncError: ""}
|
||||||
|
}
|
||||||
|
}(i, entry)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
items[r.index]["status"] = r.status
|
||||||
|
if r.syncError != "" {
|
||||||
|
items[r.index]["syncError"] = r.syncError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
|
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
|
||||||
if h == nil || h.repo == nil || shareID <= 0 {
|
if h == nil || h.repo == nil || shareID <= 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
|
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
|
||||||
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
|
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
|
||||||
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
|
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
|
||||||
|
mux.HandleFunc("/api/v1/federation/share/update", h.federationShareUpdate)
|
||||||
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
||||||
mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow)
|
mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow)
|
||||||
mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList)
|
mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList)
|
||||||
@@ -324,6 +325,9 @@ func (h *Handler) nodeList(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.syncRemoteNodeStatuses(items)
|
||||||
|
|
||||||
response.WriteJSON(w, response.OK(items))
|
response.WriteJSON(w, response.OK(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
|
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
|
||||||
response.WriteJSON(w, response.OK(cmd))
|
response.WriteJSON(w, response.OK(cmd))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,16 @@ export const createPeerShare = (data: {
|
|||||||
allowedDomains?: string;
|
allowedDomains?: string;
|
||||||
allowedIps?: string;
|
allowedIps?: string;
|
||||||
}) => Network.post("/federation/share/create", data);
|
}) => Network.post("/federation/share/create", data);
|
||||||
|
export const updatePeerShare = (data: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
maxBandwidth: number;
|
||||||
|
expiryTime: number;
|
||||||
|
portRangeStart: number;
|
||||||
|
portRangeEnd: number;
|
||||||
|
allowedDomains: string;
|
||||||
|
allowedIps: string;
|
||||||
|
}) => Network.post("/federation/share/update", data);
|
||||||
export const deletePeerShare = (id: number) =>
|
export const deletePeerShare = (id: number) =>
|
||||||
Network.post("/federation/share/delete", { id });
|
Network.post("/federation/share/delete", { id });
|
||||||
export const resetPeerShareFlow = (id: number) =>
|
export const resetPeerShareFlow = (id: number) =>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ interface Node {
|
|||||||
status: number;
|
status: number;
|
||||||
isRemote?: number;
|
isRemote?: number;
|
||||||
remoteUrl?: string;
|
remoteUrl?: string;
|
||||||
|
syncError?: string;
|
||||||
connectionStatus: "online" | "offline";
|
connectionStatus: "online" | "offline";
|
||||||
systemInfo?: {
|
systemInfo?: {
|
||||||
cpuUsage: number;
|
cpuUsage: number;
|
||||||
@@ -236,7 +237,8 @@ export default function NodePage() {
|
|||||||
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
||||||
...node,
|
...node,
|
||||||
inx: node.inx ?? 0,
|
inx: node.inx ?? 0,
|
||||||
connectionStatus: node.status === 1 ? "online" : "offline",
|
connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline",
|
||||||
|
syncError: node.syncError || undefined,
|
||||||
systemInfo: null,
|
systemInfo: null,
|
||||||
copyLoading: false,
|
copyLoading: false,
|
||||||
}));
|
}));
|
||||||
@@ -1310,6 +1312,17 @@ export default function NodePage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody className="pt-0 pb-3">
|
<CardBody className="pt-0 pb-3">
|
||||||
|
{isRemoteNode && node.syncError && (
|
||||||
|
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||||
|
{node.syncError === "provider_share_deleted"
|
||||||
|
? "提供方已删除该分享"
|
||||||
|
: node.syncError === "provider_share_disabled"
|
||||||
|
? "提供方已禁用该分享"
|
||||||
|
: node.syncError === "provider_share_expired"
|
||||||
|
? "提供方分享已过期"
|
||||||
|
: `远程同步失败: ${node.syncError}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 基础信息 */}
|
{/* 基础信息 */}
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
<div className="flex justify-between items-center text-sm min-w-0">
|
<div className="flex justify-between items-center text-sm min-w-0">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
resetPeerShareFlow,
|
resetPeerShareFlow,
|
||||||
getPeerRemoteUsageList,
|
getPeerRemoteUsageList,
|
||||||
importRemoteNode,
|
importRemoteNode,
|
||||||
|
updatePeerShare,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
@@ -77,6 +78,7 @@ interface RemoteUsageNode {
|
|||||||
usedPorts: number[];
|
usedPorts: number[];
|
||||||
bindings: RemoteUsageBinding[];
|
bindings: RemoteUsageBinding[];
|
||||||
activeBindingNum: number;
|
activeBindingNum: number;
|
||||||
|
syncError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PanelSharingPage() {
|
export default function PanelSharingPage() {
|
||||||
@@ -91,6 +93,7 @@ export default function PanelSharingPage() {
|
|||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [createShareOpen, setCreateShareOpen] = useState(false);
|
const [createShareOpen, setCreateShareOpen] = useState(false);
|
||||||
|
const [editShareOpen, setEditShareOpen] = useState(false);
|
||||||
const [importNodeOpen, setImportNodeOpen] = useState(false);
|
const [importNodeOpen, setImportNodeOpen] = useState(false);
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
@@ -110,6 +113,17 @@ export default function PanelSharingPage() {
|
|||||||
token: "",
|
token: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
maxBandwidth: 0,
|
||||||
|
expiryTime: 0,
|
||||||
|
portRangeStart: 10000,
|
||||||
|
portRangeEnd: 20000,
|
||||||
|
allowedDomains: "",
|
||||||
|
allowedIps: "",
|
||||||
|
});
|
||||||
|
|
||||||
const loadShares = useCallback(async () => {
|
const loadShares = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -239,6 +253,52 @@ export default function PanelSharingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditShare = (share: PeerShare) => {
|
||||||
|
setEditForm({
|
||||||
|
id: share.id,
|
||||||
|
name: share.name,
|
||||||
|
maxBandwidth: share.maxBandwidth > 0 ? Math.round(share.maxBandwidth / (1024 * 1024 * 1024)) : 0,
|
||||||
|
expiryTime: share.expiryTime,
|
||||||
|
portRangeStart: share.portRangeStart,
|
||||||
|
portRangeEnd: share.portRangeEnd,
|
||||||
|
allowedDomains: share.allowedDomains || "",
|
||||||
|
allowedIps: share.allowedIps || "",
|
||||||
|
});
|
||||||
|
setEditShareOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditShare = async () => {
|
||||||
|
if (!editForm.name) {
|
||||||
|
toast.error("名称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editForm.maxBandwidth < 0) {
|
||||||
|
toast.error("流量上限不能为负数");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await updatePeerShare({
|
||||||
|
id: editForm.id,
|
||||||
|
name: editForm.name,
|
||||||
|
maxBandwidth: Math.max(0, editForm.maxBandwidth) * 1024 * 1024 * 1024,
|
||||||
|
expiryTime: editForm.expiryTime,
|
||||||
|
portRangeStart: editForm.portRangeStart,
|
||||||
|
portRangeEnd: editForm.portRangeEnd,
|
||||||
|
allowedDomains: editForm.allowedDomains,
|
||||||
|
allowedIps: editForm.allowedIps,
|
||||||
|
});
|
||||||
|
if (res.code === 0) {
|
||||||
|
toast.success("编辑成功");
|
||||||
|
setEditShareOpen(false);
|
||||||
|
loadShares();
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "编辑失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("网络错误");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleImportNode = async () => {
|
const handleImportNode = async () => {
|
||||||
if (!importForm.remoteUrl || !importForm.token) {
|
if (!importForm.remoteUrl || !importForm.token) {
|
||||||
toast.error("请填写完整信息");
|
toast.error("请填写完整信息");
|
||||||
@@ -326,6 +386,13 @@ export default function PanelSharingPage() {
|
|||||||
<CardHeader className="flex justify-between">
|
<CardHeader className="flex justify-between">
|
||||||
<h3 className="font-bold">{share.name}</h3>
|
<h3 className="font-bold">{share.name}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => openEditShare(share)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -390,6 +457,17 @@ export default function PanelSharingPage() {
|
|||||||
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
|
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="text-sm space-y-2">
|
<CardBody className="text-sm space-y-2">
|
||||||
|
{node.syncError && (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||||
|
{node.syncError === "provider_share_deleted"
|
||||||
|
? "提供方已删除该分享"
|
||||||
|
: node.syncError === "provider_share_disabled"
|
||||||
|
? "提供方已禁用该分享"
|
||||||
|
: node.syncError === "provider_share_expired"
|
||||||
|
? "提供方分享已过期"
|
||||||
|
: `远程同步失败: ${node.syncError}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
||||||
<p>共享ID: {node.shareId || "-"}</p>
|
<p>共享ID: {node.shareId || "-"}</p>
|
||||||
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
||||||
@@ -491,6 +569,67 @@ export default function PanelSharingPage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Share Modal */}
|
||||||
|
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>编辑分享</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<Input
|
||||||
|
label="名称"
|
||||||
|
placeholder="备注名称"
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Input
|
||||||
|
label="起始端口"
|
||||||
|
type="number"
|
||||||
|
value={editForm.portRangeStart.toString()}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, portRangeStart: parseInt(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="结束端口"
|
||||||
|
type="number"
|
||||||
|
value={editForm.portRangeEnd.toString()}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, portRangeEnd: parseInt(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="流量上限 (GB)"
|
||||||
|
type="number"
|
||||||
|
description="0 表示不限流量"
|
||||||
|
value={editForm.maxBandwidth.toString()}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="过期时间"
|
||||||
|
type="datetime-local"
|
||||||
|
description="留空或清除表示永久有效"
|
||||||
|
value={editForm.expiryTime > 0 ? new Date(editForm.expiryTime).toISOString().slice(0, 16) : ""}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, expiryTime: e.target.value ? new Date(e.target.value).getTime() : 0 })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="允许的域名 (可选)"
|
||||||
|
placeholder="example.com, panel.test.com"
|
||||||
|
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||||
|
value={editForm.allowedDomains}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, allowedDomains: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="允许的API IP (可选)"
|
||||||
|
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||||
|
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||||
|
value={editForm.allowedIps}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, allowedIps: e.target.value })}
|
||||||
|
/>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
|
||||||
|
<Button color="primary" onPress={handleEditShare}>保存</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Import Node Modal */}
|
{/* Import Node Modal */}
|
||||||
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
|
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user