feat: adaptive traffic units and precise MB–PB quotas (#556)

## Summary

- Format traffic amounts adaptively through PB across the user,
dashboard, forwarding, node, monitoring, and panel sharing views. Closes
#548.
- Let administrators choose MB, GB, TB, or PB when setting user, tunnel
permission, and panel sharing traffic limits. Closes #549.
- Persist exact MiB limits for users and tunnel permissions while
retaining the legacy GB field and existing data. Apply the precise limit
in forwarding policy checks and preserve it in backups.

## Verification

- `go test ./...` in `go-backend` (724 passed)
- `pnpm run build` in `vite-frontend`
- ESLint on changed frontend files
- `git diff --check`
This commit is contained in:
sagit
2026-09-24 15:40:11 +08:00
committed by GitHub
21 changed files with 579 additions and 173 deletions
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"log" "log"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -12,12 +13,27 @@ import (
) )
const bytesPerGB int64 = 1024 * 1024 * 1024 const bytesPerGB int64 = 1024 * 1024 * 1024
const bytesPerMiB int64 = 1024 * 1024
func flowLimitBytes(flowGB, flowMiB int64) int64 {
if flowMiB > 0 {
if flowMiB > math.MaxInt64/bytesPerMiB {
return math.MaxInt64
}
return flowMiB * bytesPerMiB
}
if flowGB > math.MaxInt64/bytesPerGB {
return math.MaxInt64
}
return flowGB * bytesPerGB
}
type userTunnelPolicy struct { type userTunnelPolicy struct {
ID int64 ID int64
UserID int64 UserID int64
TunnelID int64 TunnelID int64
Flow int64 Flow int64
FlowMiB int64
InFlow int64 InFlow int64
OutFlow int64 OutFlow int64
ExpTime int64 ExpTime int64
@@ -358,7 +374,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
return errors.New("账号已过期") return errors.New("账号已过期")
} }
flowLimit := user.Flow * bytesPerGB flowLimit := flowLimitBytes(user.Flow, user.FlowMiB)
current := user.InFlow + user.OutFlow current := user.InFlow + user.OutFlow
if flowLimit < current { if flowLimit < current {
return errors.New("流量已超额,禁止开启转发") return errors.New("流量已超额,禁止开启转发")
@@ -400,7 +416,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
return errors.New("该隧道已过期") return errors.New("该隧道已过期")
} }
utFlowLimit := policy.Flow * bytesPerGB utFlowLimit := flowLimitBytes(policy.Flow, policy.FlowMiB)
utCurrent := policy.InFlow + policy.OutFlow utCurrent := policy.InFlow + policy.OutFlow
if utCurrent >= utFlowLimit { if utCurrent >= utFlowLimit {
return errors.New("该隧道流量已超额,禁止开启转发") return errors.New("该隧道流量已超额,禁止开启转发")
@@ -425,7 +441,7 @@ func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
return false return false
} }
flowLimit := user.Flow * bytesPerGB flowLimit := flowLimitBytes(user.Flow, user.FlowMiB)
current := user.InFlow + user.OutFlow current := user.InFlow + user.OutFlow
if flowLimit < current { if flowLimit < current {
return true return true
@@ -441,7 +457,7 @@ func shouldPauseUserTunnel(policy *userTunnelPolicy, now int64) bool {
return false return false
} }
flowLimit := policy.Flow * bytesPerGB flowLimit := flowLimitBytes(policy.Flow, policy.FlowMiB)
current := policy.InFlow + policy.OutFlow current := policy.InFlow + policy.OutFlow
if current >= flowLimit { if current >= flowLimit {
return true return true
@@ -465,7 +481,7 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
} }
return &userTunnelPolicy{ return &userTunnelPolicy{
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID, ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow, Flow: ut.Flow, FlowMiB: ut.FlowMiB, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num, ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num,
}, nil }, nil
} }
@@ -719,6 +719,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
"tunnelName": t.TunnelName, "tunnelName": t.TunnelName,
"status": t.Status, "status": t.Status,
"flow": t.Flow, "flow": t.Flow,
"flowMiB": t.FlowMiB,
"num": t.Num, "num": t.Num,
"expTime": t.ExpTime, "expTime": t.ExpTime,
"flowResetTime": t.FlowResetTime, "flowResetTime": t.FlowResetTime,
@@ -1213,6 +1214,7 @@ func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
"tunnelName": t.TunnelName, "tunnelName": t.TunnelName,
"tunnelFlow": t.TunnelFlow, "tunnelFlow": t.TunnelFlow,
"flow": t.Flow, "flow": t.Flow,
"flowMiB": t.FlowMiB,
"inFlow": t.InFlow, "inFlow": t.InFlow,
"outFlow": t.OutFlow, "outFlow": t.OutFlow,
"num": t.Num, "num": t.Num,
@@ -1262,6 +1264,7 @@ func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
"user": user.User, "user": user.User,
"status": user.Status, "status": user.Status,
"flow": user.Flow, "flow": user.Flow,
"flowMiB": user.FlowMiB,
"inFlow": user.InFlow, "inFlow": user.InFlow,
"outFlow": user.OutFlow, "outFlow": user.OutFlow,
"num": user.Num, "num": user.Num,
+65 -9
View File
@@ -57,7 +57,11 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
} }
status := asInt(req["status"], 1) status := asInt(req["status"], 1)
flow := asInt64(req["flow"], 100) flow, flowMiB, flowErr := parseTrafficLimit(req, 100)
if flowErr != nil {
response.WriteJSON(w, response.ErrDefault(flowErr.Error()))
return
}
num := asInt(req["num"], 10) num := asInt(req["num"], 10)
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()) expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
flowResetTime := asInt64(req["flowResetTime"], 1) flowResetTime := asInt64(req["flowResetTime"], 1)
@@ -76,7 +80,7 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
userID, err := h.repo.CreateUser(username, hashedPassword, roleID, expTime, flow, flowResetTime, num, status, maxConn, now) userID, err := h.repo.CreateUser(username, hashedPassword, roleID, expTime, flow, flowResetTime, num, status, maxConn, now, flowMiB)
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
@@ -164,7 +168,16 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
return return
} }
flow := asInt64(req["flow"], 100) flow, flowMiB, flowErr := parseTrafficLimit(req, 100)
if flowErr != nil {
response.WriteJSON(w, response.ErrDefault(flowErr.Error()))
return
}
if _, supplied := req["flowMiB"]; !supplied {
if current, err := h.repo.GetUserByID(id); err == nil && current != nil && current.Flow == flow {
flowMiB = current.FlowMiB
}
}
num := asInt(req["num"], 10) num := asInt(req["num"], 10)
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()) expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
flowResetTime := asInt64(req["flowResetTime"], 1) flowResetTime := asInt64(req["flowResetTime"], 1)
@@ -176,7 +189,7 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
pwd := asString(req["pwd"]) pwd := asString(req["pwd"])
if strings.TrimSpace(pwd) == "" { if strings.TrimSpace(pwd) == "" {
if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, maxConn, now); err != nil { if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, maxConn, now, flowMiB); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
@@ -186,13 +199,13 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
if err := h.repo.UpdateUserWithPassword(id, username, hashedPassword, flow, num, expTime, flowResetTime, status, maxConn, now); err != nil { if err := h.repo.UpdateUserWithPassword(id, username, hashedPassword, flow, num, expTime, flowResetTime, status, maxConn, now, flowMiB); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
} }
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime) h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime, flowMiB)
if hasDailyQuota || hasMonthlyQuota { if hasDailyQuota || hasMonthlyQuota {
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0) dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0) monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
@@ -1991,14 +2004,32 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, oldErr.Error())) response.WriteJSON(w, response.Err(-2, oldErr.Error()))
return return
} }
oldTunnel, oldTunnelErr := h.repo.GetUserTunnelByID(id)
if oldTunnelErr != nil {
response.WriteJSON(w, response.Err(-2, oldTunnelErr.Error()))
return
}
if oldTunnel == nil {
response.WriteJSON(w, response.ErrDefault("隧道权限不存在"))
return
}
flow, flowMiB, flowErr := parseTrafficLimit(req, 0)
if flowErr != nil {
response.WriteJSON(w, response.ErrDefault(flowErr.Error()))
return
}
if _, supplied := req["flowMiB"]; !supplied && oldTunnel.Flow == flow {
flowMiB = oldTunnel.FlowMiB
}
if err := h.repo.UpdateUserTunnel(id, if err := h.repo.UpdateUserTunnel(id,
asInt64(req["flow"], 0), flow,
asInt(req["num"], 0), asInt(req["num"], 0),
asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()), asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()),
asInt64(req["flowResetTime"], 1), asInt64(req["flowResetTime"], 1),
nullableInt(speedID), nullableInt(speedID),
asInt(req["status"], 1), asInt(req["status"], 1),
flowMiB,
); err != nil { ); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
@@ -2013,6 +2044,7 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
oldFlowReset, oldFlowReset,
oldSpeedID, oldSpeedID,
oldStatus, oldStatus,
oldTunnel.FlowMiB,
) )
if rollbackErr != nil { if rollbackErr != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("下发失败且回滚失败: %v; 回滚错误: %v", syncErr, rollbackErr))) response.WriteJSON(w, response.Err(-2, fmt.Sprintf("下发失败且回滚失败: %v; 回滚错误: %v", syncErr, rollbackErr)))
@@ -4781,6 +4813,14 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
} }
reqFlow := asInt64(req["flow"], -1) reqFlow := asInt64(req["flow"], -1)
var reqFlowMiB int64
if _, hasFlowMiB := req["flowMiB"]; hasFlowMiB {
var flowErr error
reqFlow, reqFlowMiB, flowErr = parseTrafficLimit(req, 0)
if flowErr != nil {
return flowErr
}
}
reqNum := asInt(req["num"], -1) reqNum := asInt(req["num"], -1)
reqExpTime := asInt64(req["expTime"], -1) reqExpTime := asInt64(req["expTime"], -1)
reqFlowReset := asInt64(req["flowResetTime"], -1) reqFlowReset := asInt64(req["flowResetTime"], -1)
@@ -4792,6 +4832,9 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
if uErr == nil { if uErr == nil {
if reqFlow < 0 { if reqFlow < 0 {
reqFlow = uFlow reqFlow = uFlow
if user, err := h.repo.GetUserByID(userID); err == nil && user != nil {
reqFlowMiB = user.FlowMiB
}
} }
if reqNum < 0 { if reqNum < 0 {
reqNum = uNum reqNum = uNum
@@ -4820,7 +4863,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
reqStatus = 1 reqStatus = 1
} }
if err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus); err != nil { if err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus, reqFlowMiB); err != nil {
return err return err
} }
@@ -4844,8 +4887,20 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
} }
newFlow := currentFlow newFlow := currentFlow
oldTunnel, err := h.repo.GetUserTunnelByID(existingID)
if err != nil {
return err
}
if oldTunnel == nil {
return fmt.Errorf("隧道权限不存在")
}
newFlowMiB := oldTunnel.FlowMiB
if reqFlow >= 0 { if reqFlow >= 0 {
newFlow = reqFlow newFlow = reqFlow
newFlowMiB = reqFlowMiB
if _, supplied := req["flowMiB"]; !supplied && reqFlow == currentFlow {
newFlowMiB = oldTunnel.FlowMiB
}
} }
newNum := int(currentNum) newNum := int(currentNum)
@@ -4875,7 +4930,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
newSpeedID = sql.NullInt64{Valid: false} newSpeedID = sql.NullInt64{Valid: false}
} }
if err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil { if err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus, newFlowMiB); err != nil {
return err return err
} }
@@ -4888,6 +4943,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
currentExpTime, currentExpTime,
currentFlowReset, currentFlowReset,
currentStatus, currentStatus,
oldTunnel.FlowMiB,
) )
if rollbackErr != nil { if rollbackErr != nil {
return fmt.Errorf("下发失败且回滚失败: %v; 回滚错误: %w", syncErr, rollbackErr) return fmt.Errorf("下发失败且回滚失败: %v; 回滚错误: %w", syncErr, rollbackErr)
@@ -0,0 +1,28 @@
package handler
import (
"fmt"
"math"
"strconv"
)
// flowMiB is optional so older clients can keep sending the GB-based flow field.
// A positive value takes precedence and preserves sub-GB limits exactly.
func parseTrafficLimit(req map[string]interface{}, defaultGB int64) (flowGB, flowMiB int64, err error) {
flowGB = asInt64(req["flow"], defaultGB)
if flowGB < 0 {
return 0, 0, fmt.Errorf("流量限制不能小于0")
}
raw, present := req["flowMiB"]
if !present {
return flowGB, 0, nil
}
flowMiB, err = strconv.ParseInt(asString(raw), 10, 64)
if err != nil || flowMiB < 0 || flowMiB > math.MaxInt64/bytesPerMiB {
return 0, 0, fmt.Errorf("流量限制超出范围")
}
if flowMiB > 0 {
flowGB = (flowMiB-1)/1024 + 1
}
return flowGB, flowMiB, nil
}
@@ -0,0 +1,39 @@
package handler
import (
"testing"
"time"
)
func TestTrafficLimitMiBOverridesLegacyGB(t *testing.T) {
flowGB, flowMiB, err := parseTrafficLimit(map[string]interface{}{
"flow": float64(1), "flowMiB": float64(500),
}, 100)
if err != nil || flowGB != 1 || flowMiB != 500 {
t.Fatalf("parseTrafficLimit = (%d, %d, %v), want (1, 500, nil)", flowGB, flowMiB, err)
}
limit := flowLimitBytes(flowGB, flowMiB)
if limit != 500*bytesPerMiB {
t.Fatalf("limit = %d, want %d", limit, 500*bytesPerMiB)
}
policy := &userTunnelPolicy{Flow: flowGB, FlowMiB: flowMiB, InFlow: limit - 1, Status: 1}
if shouldPauseUserTunnel(policy, time.Now().UnixMilli()) {
t.Fatal("policy paused before reaching 500 MiB")
}
policy.InFlow = limit
if !shouldPauseUserTunnel(policy, time.Now().UnixMilli()) {
t.Fatal("policy did not pause at 500 MiB")
}
}
func TestTrafficLimitLegacyAndInvalidValues(t *testing.T) {
flowGB, flowMiB, err := parseTrafficLimit(map[string]interface{}{"flow": float64(2)}, 100)
if err != nil || flowGB != 2 || flowMiB != 0 || flowLimitBytes(flowGB, flowMiB) != 2*bytesPerGB {
t.Fatalf("legacy GB limit changed: (%d, %d, %v)", flowGB, flowMiB, err)
}
for _, value := range []interface{}{"1.5", -1, "999999999999999999999"} {
if _, _, err := parseTrafficLimit(map[string]interface{}{"flowMiB": value}, 100); err == nil {
t.Fatalf("accepted invalid flowMiB %v", value)
}
}
}
+5
View File
@@ -16,6 +16,7 @@ type User struct {
RoleID int `gorm:"column:role_id;not null"` RoleID int `gorm:"column:role_id;not null"`
ExpTime int64 `gorm:"column:exp_time;not null"` ExpTime int64 `gorm:"column:exp_time;not null"`
Flow int64 `gorm:"not null"` Flow int64 `gorm:"not null"`
FlowMiB int64 `gorm:"column:flow_mib;not null;default:0"`
InFlow int64 `gorm:"column:in_flow;not null;default:0"` InFlow int64 `gorm:"column:in_flow;not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"` OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"` FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
@@ -230,6 +231,7 @@ type UserTunnel struct {
SpeedID sql.NullInt64 `gorm:"column:speed_id"` SpeedID sql.NullInt64 `gorm:"column:speed_id"`
Num int `gorm:"not null"` Num int `gorm:"not null"`
Flow int64 `gorm:"not null"` Flow int64 `gorm:"not null"`
FlowMiB int64 `gorm:"column:flow_mib;not null;default:0"`
InFlow int64 `gorm:"column:in_flow;not null;default:0"` InFlow int64 `gorm:"column:in_flow;not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"` OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"` FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
@@ -417,6 +419,7 @@ type UserBackup struct {
RoleID int `json:"roleId"` RoleID int `json:"roleId"`
ExpTime int64 `json:"expTime"` ExpTime int64 `json:"expTime"`
Flow int64 `json:"flow"` Flow int64 `json:"flow"`
FlowMiB int64 `json:"flowMiB,omitempty"`
InFlow int64 `json:"inFlow"` InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"` OutFlow int64 `json:"outFlow"`
FlowResetTime int64 `json:"flowResetTime"` FlowResetTime int64 `json:"flowResetTime"`
@@ -523,6 +526,7 @@ type UserTunnelBackup struct {
SpeedID int64 `json:"speedId,omitempty"` SpeedID int64 `json:"speedId,omitempty"`
Num int `json:"num"` Num int `json:"num"`
Flow int64 `json:"flow"` Flow int64 `json:"flow"`
FlowMiB int64 `json:"flowMiB,omitempty"`
InFlow int64 `json:"inFlow"` InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"` OutFlow int64 `json:"outFlow"`
FlowResetTime int64 `json:"flowResetTime"` FlowResetTime int64 `json:"flowResetTime"`
@@ -706,6 +710,7 @@ type UserTunnelDetail struct {
Status int Status int
TunnelFlow int TunnelFlow int
Flow int64 Flow int64
FlowMiB int64 `gorm:"column:flow_mib"`
InFlow int64 InFlow int64
OutFlow int64 OutFlow int64
Num int Num int
+8 -6
View File
@@ -669,7 +669,7 @@ func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDeta
} }
var items []model.UserTunnelDetail var items []model.UserTunnelDetail
err := r.db.Model(&model.UserTunnel{}). err := r.db.Model(&model.UserTunnel{}).
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed"). Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.flow_mib, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id"). Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id").
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id"). Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
Where("user_tunnel.user_id = ?", userID). Where("user_tunnel.user_id = ?", userID).
@@ -924,7 +924,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
item := map[string]interface{}{ item := map[string]interface{}{
"id": u.ID, "user": u.User, "name": u.User, "id": u.ID, "user": u.User, "name": u.User,
"roleId": u.RoleID, "status": u.Status, "roleId": u.RoleID, "status": u.Status,
"flow": u.Flow, "num": u.Num, "expTime": u.ExpTime, "flow": u.Flow, "flowMiB": u.FlowMiB, "num": u.Num, "expTime": u.ExpTime,
"flowResetTime": u.FlowResetTime, "createdTime": u.CreatedTime, "flowResetTime": u.FlowResetTime, "createdTime": u.CreatedTime,
"updatedTime": nullableInt64(u.UpdatedTime), "updatedTime": nullableInt64(u.UpdatedTime),
"inFlow": u.InFlow, "outFlow": u.OutFlow, "inFlow": u.InFlow, "outFlow": u.OutFlow,
@@ -2094,7 +2094,7 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
for _, u := range users { for _, u := range users {
b := model.UserBackup{ b := model.UserBackup{
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID, ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
ExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow, ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, InFlow: u.InFlow, OutFlow: u.OutFlow,
FlowResetTime: u.FlowResetTime, Num: u.Num, FlowResetTime: u.FlowResetTime, Num: u.Num,
CreatedTime: u.CreatedTime, Status: u.Status, CreatedTime: u.CreatedTime, Status: u.Status,
} }
@@ -2270,7 +2270,7 @@ func (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {
for _, ut := range uts { for _, ut := range uts {
b := model.UserTunnelBackup{ b := model.UserTunnelBackup{
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID, ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
Num: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow, Num: ut.Num, Flow: ut.Flow, FlowMiB: ut.FlowMiB, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
FlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status, FlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,
} }
if ut.SpeedID.Valid { if ut.SpeedID.Valid {
@@ -2467,6 +2467,7 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
RoleID: u.RoleID, RoleID: u.RoleID,
ExpTime: u.ExpTime, ExpTime: u.ExpTime,
Flow: u.Flow, Flow: u.Flow,
FlowMiB: u.FlowMiB,
InFlow: u.InFlow, InFlow: u.InFlow,
OutFlow: u.OutFlow, OutFlow: u.OutFlow,
FlowResetTime: u.FlowResetTime, FlowResetTime: u.FlowResetTime,
@@ -2479,7 +2480,7 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
err = tx.Clauses(clause.OnConflict{ err = tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}}, Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{ DoUpdates: clause.AssignmentColumns([]string{
"user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow", "user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
"flow_reset_time", "num", "updated_time", "status", "password_changed_at", "flow_reset_time", "num", "updated_time", "status", "password_changed_at",
}), }),
}).Create(&item).Error }).Create(&item).Error
@@ -2717,6 +2718,7 @@ func importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int6
SpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0}, SpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},
Num: ut.Num, Num: ut.Num,
Flow: ut.Flow, Flow: ut.Flow,
FlowMiB: ut.FlowMiB,
InFlow: ut.InFlow, InFlow: ut.InFlow,
OutFlow: ut.OutFlow, OutFlow: ut.OutFlow,
FlowResetTime: ut.FlowResetTime, FlowResetTime: ut.FlowResetTime,
@@ -2726,7 +2728,7 @@ func importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int6
err := tx.Clauses(clause.OnConflict{ err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}}, Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{ DoUpdates: clause.AssignmentColumns([]string{
"user_id", "tunnel_id", "speed_id", "num", "flow", "in_flow", "out_flow", "user_id", "tunnel_id", "speed_id", "num", "flow", "flow_mib", "in_flow", "out_flow",
"flow_reset_time", "exp_time", "status", "flow_reset_time", "exp_time", "status",
}), }),
}).Create(&item).Error }).Create(&item).Error
@@ -37,7 +37,14 @@ func (r *Repository) UserExistsExcluding(username string, excludeID int64) (bool
return cnt > 0, err return cnt > 0, err
} }
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status, maxConn int, now int64) (int64, error) { func optionalFlowMiB(values []int64) int64 {
if len(values) > 0 {
return values[0]
}
return 0
}
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status, maxConn int, now int64, flowMiB ...int64) (int64, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return 0, errors.New("repository not initialized") return 0, errors.New("repository not initialized")
} }
@@ -47,6 +54,7 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
RoleID: roleID, RoleID: roleID,
ExpTime: expTime, ExpTime: expTime,
Flow: flow, Flow: flow,
FlowMiB: optionalFlowMiB(flowMiB),
InFlow: 0, InFlow: 0,
OutFlow: 0, OutFlow: 0,
FlowResetTime: flowResetTime, FlowResetTime: flowResetTime,
@@ -75,7 +83,7 @@ func (r *Repository) GetUserRoleID(userID int64) (int, error) {
return user.RoleID, nil return user.RoleID, nil
} }
func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error { func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64, flowMiB ...int64) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return errors.New("repository not initialized") return errors.New("repository not initialized")
} }
@@ -85,6 +93,7 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string,
"user": username, "user": username,
"pwd": pwdHash, "pwd": pwdHash,
"flow": flow, "flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num, "num": num,
"exp_time": expTime, "exp_time": expTime,
"flow_reset_time": flowResetTime, "flow_reset_time": flowResetTime,
@@ -95,7 +104,7 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string,
}).Error }).Error
} }
func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error { func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64, flowMiB ...int64) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return errors.New("repository not initialized") return errors.New("repository not initialized")
} }
@@ -104,6 +113,7 @@ func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow i
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"user": username, "user": username,
"flow": flow, "flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num, "num": num,
"exp_time": expTime, "exp_time": expTime,
"flow_reset_time": flowResetTime, "flow_reset_time": flowResetTime,
@@ -126,7 +136,7 @@ func (r *Repository) UpdateUserPassword(userID int64, pwdHash string, now int64)
}).Error }).Error
} }
func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num int, expTime, flowResetTime int64) { func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num int, expTime, flowResetTime int64, flowMiB ...int64) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return return
} }
@@ -134,6 +144,7 @@ func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num in
Where("user_id = ?", userID). Where("user_id = ?", userID).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"flow": flow, "flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num, "num": num,
"exp_time": expTime, "exp_time": expTime,
"flow_reset_time": flowResetTime, "flow_reset_time": flowResetTime,
@@ -650,7 +661,7 @@ func (r *Repository) DeleteUserTunnel(id int64) error {
return r.db.Where("id = ?", id).Delete(&model.UserTunnel{}).Error return r.db.Where("id = ?", id).Delete(&model.UserTunnel{}).Error
} }
func (r *Repository) UpdateUserTunnel(id int64, flow int64, num int, expTime, flowResetTime int64, speedID interface{}, status int) error { func (r *Repository) UpdateUserTunnel(id int64, flow int64, num int, expTime, flowResetTime int64, speedID interface{}, status int, flowMiB ...int64) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return errors.New("repository not initialized") return errors.New("repository not initialized")
} }
@@ -658,6 +669,7 @@ func (r *Repository) UpdateUserTunnel(id int64, flow int64, num int, expTime, fl
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"flow": flow, "flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num, "num": num,
"exp_time": expTime, "exp_time": expTime,
"flow_reset_time": flowResetTime, "flow_reset_time": flowResetTime,
@@ -692,7 +704,7 @@ func (r *Repository) GetExistingUserTunnel(userID, tunnelID int64) (id int64, fl
return ut.ID, ut.Flow, int64(ut.Num), ut.ExpTime, ut.FlowResetTime, ut.SpeedID, ut.Status, nil return ut.ID, ut.Flow, int64(ut.Num), ut.ExpTime, ut.FlowResetTime, ut.SpeedID, ut.Status, nil
} }
func (r *Repository) InsertUserTunnel(userID, tunnelID int64, speedID interface{}, num int, flow, flowResetTime, expTime int64, status int) error { func (r *Repository) InsertUserTunnel(userID, tunnelID int64, speedID interface{}, num int, flow, flowResetTime, expTime int64, status int, flowMiB ...int64) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return errors.New("repository not initialized") return errors.New("repository not initialized")
} }
@@ -702,6 +714,7 @@ func (r *Repository) InsertUserTunnel(userID, tunnelID int64, speedID interface{
SpeedID: nullInt64FromInterface(speedID), SpeedID: nullInt64FromInterface(speedID),
Num: num, Num: num,
Flow: flow, Flow: flow,
FlowMiB: optionalFlowMiB(flowMiB),
InFlow: 0, InFlow: 0,
OutFlow: 0, OutFlow: 0,
FlowResetTime: flowResetTime, FlowResetTime: flowResetTime,
@@ -711,7 +724,7 @@ func (r *Repository) InsertUserTunnel(userID, tunnelID int64, speedID interface{
return r.db.Create(&ut).Error return r.db.Create(&ut).Error
} }
func (r *Repository) UpdateUserTunnelFields(id int64, speedID interface{}, flow int64, num int, expTime, flowResetTime int64, status int) error { func (r *Repository) UpdateUserTunnelFields(id int64, speedID interface{}, flow int64, num int, expTime, flowResetTime int64, status int, flowMiB ...int64) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return errors.New("repository not initialized") return errors.New("repository not initialized")
} }
@@ -720,6 +733,7 @@ func (r *Repository) UpdateUserTunnelFields(id int64, speedID interface{}, flow
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"speed_id": nullInt64FromInterface(speedID), "speed_id": nullInt64FromInterface(speedID),
"flow": flow, "flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num, "num": num,
"exp_time": expTime, "exp_time": expTime,
"flow_reset_time": flowResetTime, "flow_reset_time": flowResetTime,
@@ -1293,7 +1307,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
return 0, false, err return 0, false, err
} }
var user model.User var user model.User
if err := r.db.Select("flow, num, exp_time, flow_reset_time").Where("id = ?", userID).First(&user).Error; err != nil { if err := r.db.Select("flow, flow_mib, num, exp_time, flow_reset_time").Where("id = ?", userID).First(&user).Error; err != nil {
return 0, false, err return 0, false, err
} }
flow := user.Flow flow := user.Flow
@@ -1305,6 +1319,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
TunnelID: tunnelID, TunnelID: tunnelID,
Num: num, Num: num,
Flow: flow, Flow: flow,
FlowMiB: user.FlowMiB,
InFlow: 0, InFlow: 0,
OutFlow: 0, OutFlow: 0,
FlowResetTime: flowReset, FlowResetTime: flowReset,
@@ -0,0 +1,67 @@
package repo
import (
"path/filepath"
"testing"
"time"
"go-backend/internal/store/model"
)
func TestTrafficLimitMiBSurvivesBackupRestore(t *testing.T) {
source, err := Open(filepath.Join(t.TempDir(), "source.db"))
if err != nil {
t.Fatal(err)
}
defer source.Close()
now := time.Now().UnixMilli()
userID, err := source.CreateUser("mib-user", "hash", 1, now+86400000, 1, 1, 10, 1, 0, now, 500)
if err != nil {
t.Fatal(err)
}
tunnel := model.Tunnel{Name: "mib-tunnel", TrafficRatio: 1, Type: 1, Protocol: "tls", Flow: 1, CreatedTime: now, UpdatedTime: now, Status: 1, Inx: 1}
if err := source.DB().Create(&tunnel).Error; err != nil {
t.Fatal(err)
}
if _, _, err := source.EnsureUserTunnelGrant(userID, tunnel.ID); err != nil {
t.Fatal(err)
}
grants, err := source.GetUserPackageTunnels(userID)
if err != nil || len(grants) != 1 || grants[0].FlowMiB != 500 {
t.Fatalf("inherited tunnel quota = %+v, err = %v", grants, err)
}
backup, err := source.ExportAll()
if err != nil {
t.Fatal(err)
}
found := false
for _, user := range backup.Users {
if user.User == "mib-user" {
found = user.Flow == 1 && user.FlowMiB == 500
}
}
if !found {
t.Fatal("500 MiB user quota missing from backup")
}
if len(backup.UserTunnels) != 1 || backup.UserTunnels[0].FlowMiB != 500 {
t.Fatalf("tunnel quota missing from backup: %+v", backup.UserTunnels)
}
dest, err := Open(filepath.Join(t.TempDir(), "dest.db"))
if err != nil {
t.Fatal(err)
}
defer dest.Close()
if _, err := dest.Import(backup, []string{"users", "tunnels", "userTunnels"}); err != nil {
t.Fatal(err)
}
user, err := dest.GetUserByUsername("mib-user")
if err != nil || user == nil || user.Flow != 1 || user.FlowMiB != 500 {
t.Fatalf("restored quota = %+v, err = %v", user, err)
}
grants, err = dest.GetUserPackageTunnels(user.ID)
if err != nil || len(grants) != 1 || grants[0].FlowMiB != 500 {
t.Fatalf("restored tunnel quota = %+v, err = %v", grants, err)
}
}
+3
View File
@@ -19,6 +19,7 @@ export interface UserApiItem {
name?: string; name?: string;
status: number; status: number;
flow: number; flow: number;
flowMiB?: number;
num: number; num: number;
expTime?: number; expTime?: number;
flowResetTime?: number; flowResetTime?: number;
@@ -106,6 +107,7 @@ export interface UserTunnelPermissionApiItem {
tunnelName: string; tunnelName: string;
status: number; status: number;
flow: number; flow: number;
flowMiB?: number;
num: number; num: number;
expTime: number; expTime: number;
flowResetTime: number; flowResetTime: number;
@@ -387,6 +389,7 @@ export interface UserTunnelAssignPayload {
id?: number; id?: number;
tunnelId?: number; tunnelId?: number;
flow?: number; flow?: number;
flowMiB?: number;
num?: number; num?: number;
expTime?: number; expTime?: number;
flowResetTime?: number; flowResetTime?: number;
@@ -0,0 +1,62 @@
import { Input } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
parseTrafficInput,
TRAFFIC_UNIT_MIB,
type TrafficUnit,
} from "@/utils/traffic";
const UNITS: TrafficUnit[] = ["MB", "GB", "TB", "PB"];
interface TrafficLimitFieldProps {
label: string;
value: string;
unit: TrafficUnit;
onChange: (value: string, unit: TrafficUnit) => void;
description?: string;
isRequired?: boolean;
}
export function TrafficLimitField({
label,
value,
unit,
onChange,
description,
isRequired,
}: TrafficLimitFieldProps) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_7rem] gap-2">
<Input
description={description}
isRequired={isRequired}
label={label}
min="0"
step="any"
type="number"
value={value}
onChange={(event) => onChange(event.target.value, unit)}
/>
<Select
aria-label={`${label}单位`}
label="单位"
selectedKeys={[unit]}
onSelectionChange={(keys) => {
const nextUnit = Array.from(keys)[0] as TrafficUnit | undefined;
if (!nextUnit) return;
const mib = parseTrafficInput(value, unit);
onChange(
mib === null ? value : String(mib / TRAFFIC_UNIT_MIB[nextUnit]),
nextUnit,
);
}}
>
{UNITS.map((option) => (
<SelectItem key={option}>{option}</SelectItem>
))}
</Select>
</div>
);
}
+15 -27
View File
@@ -24,6 +24,11 @@ import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
import { MetricCard } from "@/pages/dashboard/components/metric-card"; import { MetricCard } from "@/pages/dashboard/components/metric-card";
import { getSessionName } from "@/utils/session"; import { getSessionName } from "@/utils/session";
import { safeLogout } from "@/utils/logout"; import { safeLogout } from "@/utils/logout";
import {
formatTraffic,
formatFlowLimit,
flowLimitBytes,
} from "@/utils/traffic";
import { import {
formatNodeRenewalTime, formatNodeRenewalTime,
getNodeRenewalCycleLabel, getNodeRenewalCycleLabel,
@@ -70,24 +75,7 @@ export default function DashboardPage() {
const [addressModalTitle, setAddressModalTitle] = useState(""); const [addressModalTitle, setAddressModalTitle] = useState("");
const [addressList, setAddressList] = useState<AddressItem[]>([]); const [addressList, setAddressList] = useState<AddressItem[]>([]);
const formatFlow = (value: number, unit: string = "bytes"): string => { const formatFlow = formatTraffic;
// 99999 表示无限制
if (value === 99999) {
return "无限制";
}
if (unit === "gb") {
return value + " GB";
} else {
if (value === 0) return "0 B";
if (value < 1024) return value + " B";
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + " KB";
if (value < 1024 * 1024 * 1024)
return (value / (1024 * 1024)).toFixed(2) + " MB";
return (value / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}
};
const formatNumber = (value: number): string => { const formatNumber = (value: number): string => {
// 99999 表示无限制 // 99999 表示无限制
@@ -279,10 +267,10 @@ export default function DashboardPage() {
const calculateUsagePercentage = (type: "flow" | "forwards"): number => { const calculateUsagePercentage = (type: "flow" | "forwards"): number => {
if (type === "flow") { if (type === "flow") {
const totalUsed = calculateUserTotalUsedFlow(); const totalUsed = calculateUserTotalUsedFlow();
const totalLimit = (userInfo.flow || 0) * 1024 * 1024 * 1024; const totalLimit = flowLimitBytes(userInfo.flow || 0, userInfo.flowMiB);
// 无限制时返回0% // 无限制时返回0%
if (userInfo.flow === 99999) return 0; if (userInfo.flow === 99999 && !userInfo.flowMiB) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0; return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
} else if (type === "forwards") { } else if (type === "forwards") {
@@ -351,10 +339,10 @@ export default function DashboardPage() {
const calculateTunnelFlowPercentage = (tunnel: UserTunnel): number => { const calculateTunnelFlowPercentage = (tunnel: UserTunnel): number => {
const totalUsed = calculateTunnelUsedFlow(tunnel); const totalUsed = calculateTunnelUsedFlow(tunnel);
const totalLimit = (tunnel.flow || 0) * 1024 * 1024 * 1024; const totalLimit = flowLimitBytes(tunnel.flow || 0, tunnel.flowMiB);
// 无限制时返回0% // 无限制时返回0%
if (tunnel.flow === 99999) return 0; if (tunnel.flow === 99999 && !tunnel.flowMiB) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0; return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
}; };
@@ -741,7 +729,7 @@ export default function DashboardPage() {
} }
iconClassName="bg-blue-100 dark:bg-blue-500/20" iconClassName="bg-blue-100 dark:bg-blue-500/20"
title="总流量" title="总流量"
value={formatFlow(userInfo.flow, "gb")} value={formatFlowLimit(userInfo.flow, userInfo.flowMiB)}
/> />
<MetricCard <MetricCard
@@ -750,11 +738,11 @@ export default function DashboardPage() {
{renderProgressBar( {renderProgressBar(
calculateUsagePercentage("flow"), calculateUsagePercentage("flow"),
"sm", "sm",
userInfo.flow === 99999, userInfo.flow === 99999 && !userInfo.flowMiB,
)} )}
<div className="flex items-center justify-between mt-1"> <div className="flex items-center justify-between mt-1">
<p className="text-xs text-default-500 truncate"> <p className="text-xs text-default-500 truncate">
{userInfo.flow === 99999 {userInfo.flow === 99999 && !userInfo.flowMiB
? "无限制" ? "无限制"
: `${calculateUsagePercentage("flow").toFixed(1)}%`} : `${calculateUsagePercentage("flow").toFixed(1)}%`}
</p> </p>
@@ -981,7 +969,7 @@ export default function DashboardPage() {
流量配额 流量配额
</p> </p>
<p className="font-semibold text-foreground"> <p className="font-semibold text-foreground">
{formatFlow(tunnel.flow, "gb")} {formatFlowLimit(tunnel.flow, tunnel.flowMiB)}
</p> </p>
</div> </div>
<div> <div>
@@ -995,7 +983,7 @@ export default function DashboardPage() {
{renderProgressBar( {renderProgressBar(
calculateTunnelFlowPercentage(tunnel), calculateTunnelFlowPercentage(tunnel),
"sm", "sm",
tunnel.flow === 99999, tunnel.flow === 99999 && !tunnel.flowMiB,
)} )}
</div> </div>
</div> </div>
@@ -14,6 +14,7 @@ import { getAdminFlag } from "@/utils/session";
export interface DashboardUserInfo { export interface DashboardUserInfo {
flow: number; flow: number;
flowMiB?: number;
inFlow: number; inFlow: number;
outFlow: number; outFlow: number;
num: number; num: number;
@@ -26,6 +27,7 @@ export interface DashboardUserTunnel {
tunnelId: number; tunnelId: number;
tunnelName: string; tunnelName: string;
flow: number; flow: number;
flowMiB?: number;
inFlow: number; inFlow: number;
outFlow: number; outFlow: number;
num: number; num: number;
+2 -7
View File
@@ -27,6 +27,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { AnimatedPage } from "@/components/animated-page"; import { AnimatedPage } from "@/components/animated-page";
import { formatTraffic } from "@/utils/traffic";
import { BatchActionResultModal } from "@/components/batch-action-result-modal"; import { BatchActionResultModal } from "@/components/batch-action-result-modal";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
@@ -2704,13 +2705,7 @@ export default function ForwardPage() {
// 格式化流量 // 格式化流量
const formatFlow = (value: number): string => { const formatFlow = (value: number): string => {
if (value === 0) return "0 B"; return formatTraffic(value);
if (value < 1024) return value + " B";
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + " KB";
if (value < 1024 * 1024 * 1024)
return (value / (1024 * 1024)).toFixed(2) + " MB";
return (value / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}; };
// 显示地址列表弹窗 // 显示地址列表弹窗
+2 -9
View File
@@ -20,6 +20,7 @@ import { CSS } from "@dnd-kit/utilities";
import { LayoutGrid, List } from "lucide-react"; import { LayoutGrid, List } from "lucide-react";
import { SearchBar } from "@/components/search-bar"; import { SearchBar } from "@/components/search-bar";
import { formatTraffic } from "@/utils/traffic";
import { AnimatedPage } from "@/components/animated-page"; import { AnimatedPage } from "@/components/animated-page";
import { import {
Table, Table,
@@ -734,15 +735,7 @@ export default function NodePage() {
// 格式化流量 // 格式化流量
const formatFlow = (bytes: number): string => { const formatFlow = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) { return formatTraffic(bytes);
return "0 B";
}
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}; };
const formatChainType = (chainType: number, hopInx: number) => { const formatChainType = (chainType: number, hopInx: number) => {
@@ -33,6 +33,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { formatTraffic } from "@/utils/traffic";
import { import {
DistroIcon, DistroIcon,
parseDistroFromVersion, parseDistroFromVersion,
@@ -137,15 +138,7 @@ const formatDateTime = (ts: number): string => {
}); });
}; };
const formatBytes = (bytes: number): string => { const formatBytes = formatTraffic;
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
const formatBytesPerSecond = (bytesPerSecond: number): string => { const formatBytesPerSecond = (bytesPerSecond: number): string => {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "0 B/s"; if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "0 B/s";
@@ -36,6 +36,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { formatTraffic } from "@/utils/traffic";
import { import {
getMonitorTunnels, getMonitorTunnels,
getTunnelMetrics, getTunnelMetrics,
@@ -390,12 +391,7 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
const yFormatter = (value: unknown) => { const yFormatter = (value: unknown) => {
const n = Number(value); const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return "0 B"; return formatTraffic(n);
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(k));
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}; };
return ( return (
+71 -39
View File
@@ -5,6 +5,15 @@ import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs"; import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs";
import { Input } from "@/shadcn-bridge/heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { TrafficLimitField } from "@/components/traffic-limit-field";
import {
formatTraffic,
MIB,
parseTrafficInput,
preferredTrafficUnit,
TRAFFIC_UNIT_MIB,
type TrafficUnit,
} from "@/utils/traffic";
import { import {
Modal, Modal,
ModalContent, ModalContent,
@@ -82,6 +91,8 @@ interface RemoteUsageNode {
syncError?: string; syncError?: string;
} }
const MAX_SAFE_BANDWIDTH_MIB = Math.floor(Number.MAX_SAFE_INTEGER / MIB);
export default function PanelSharingPage() { export default function PanelSharingPage() {
const [selectedTab, setSelectedTab] = useState("my-shares"); const [selectedTab, setSelectedTab] = useState("my-shares");
const [shares, setShares] = useState<PeerShare[]>([]); const [shares, setShares] = useState<PeerShare[]>([]);
@@ -108,6 +119,7 @@ export default function PanelSharingPage() {
allowedDomains: "", allowedDomains: "",
allowedIps: "", allowedIps: "",
}); });
const [shareUnit, setShareUnit] = useState<TrafficUnit>("GB");
const [importForm, setImportForm] = useState({ const [importForm, setImportForm] = useState({
remoteUrl: "", remoteUrl: "",
@@ -124,6 +136,9 @@ export default function PanelSharingPage() {
allowedDomains: "", allowedDomains: "",
allowedIps: "", allowedIps: "",
}); });
const [editUnit, setEditUnit] = useState<TrafficUnit>("GB");
const [editOriginalMaxBandwidth, setEditOriginalMaxBandwidth] = useState(0);
const [editBandwidthChanged, setEditBandwidthChanged] = useState(false);
const loadShares = useCallback(async () => { const loadShares = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -212,8 +227,17 @@ export default function PanelSharingPage() {
return; return;
} }
if (shareForm.maxBandwidth < 0) { const limitMiB =
toast.error("流量上限不能为负数"); shareForm.maxBandwidth === 0
? 0
: parseTrafficInput(String(shareForm.maxBandwidth), shareUnit);
if (
limitMiB === null ||
limitMiB > MAX_SAFE_BANDWIDTH_MIB ||
shareForm.maxBandwidth < 0
) {
toast.error("请输入有效的流量上限,0 表示不限流量");
return; return;
} }
@@ -223,7 +247,7 @@ export default function PanelSharingPage() {
const res = await createPeerShare({ const res = await createPeerShare({
name: shareForm.name, name: shareForm.name,
nodeId, nodeId,
maxBandwidth: Math.max(0, shareForm.maxBandwidth) * 1024 * 1024 * 1024, maxBandwidth: limitMiB * MIB,
expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime, expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime,
portRangeStart: shareForm.portRangeStart, portRangeStart: shareForm.portRangeStart,
portRangeEnd: shareForm.portRangeEnd, portRangeEnd: shareForm.portRangeEnd,
@@ -274,13 +298,16 @@ export default function PanelSharingPage() {
}; };
const openEditShare = (share: PeerShare) => { const openEditShare = (share: PeerShare) => {
const mib = share.maxBandwidth / MIB;
const unit = preferredTrafficUnit(mib);
setEditUnit(unit);
setEditOriginalMaxBandwidth(share.maxBandwidth);
setEditBandwidthChanged(false);
setEditForm({ setEditForm({
id: share.id, id: share.id,
name: share.name, name: share.name,
maxBandwidth: maxBandwidth: share.maxBandwidth > 0 ? mib / TRAFFIC_UNIT_MIB[unit] : 0,
share.maxBandwidth > 0
? Math.round(share.maxBandwidth / (1024 * 1024 * 1024))
: 0,
expiryTime: share.expiryTime, expiryTime: share.expiryTime,
portRangeStart: share.portRangeStart, portRangeStart: share.portRangeStart,
portRangeEnd: share.portRangeEnd, portRangeEnd: share.portRangeEnd,
@@ -296,8 +323,18 @@ export default function PanelSharingPage() {
return; return;
} }
if (editForm.maxBandwidth < 0) { const limitMiB =
toast.error("流量上限不能为负数"); editForm.maxBandwidth === 0
? 0
: parseTrafficInput(String(editForm.maxBandwidth), editUnit);
if (
editBandwidthChanged &&
(limitMiB === null ||
limitMiB > MAX_SAFE_BANDWIDTH_MIB ||
editForm.maxBandwidth < 0)
) {
toast.error("请输入有效的流量上限,0 表示不限流量");
return; return;
} }
@@ -305,7 +342,9 @@ export default function PanelSharingPage() {
const res = await updatePeerShare({ const res = await updatePeerShare({
id: editForm.id, id: editForm.id,
name: editForm.name, name: editForm.name,
maxBandwidth: Math.max(0, editForm.maxBandwidth) * 1024 * 1024 * 1024, maxBandwidth: editBandwidthChanged
? (limitMiB as number) * MIB
: editOriginalMaxBandwidth,
expiryTime: editForm.expiryTime, expiryTime: editForm.expiryTime,
portRangeStart: editForm.portRangeStart, portRangeStart: editForm.portRangeStart,
portRangeEnd: editForm.portRangeEnd, portRangeEnd: editForm.portRangeEnd,
@@ -362,17 +401,7 @@ export default function PanelSharingPage() {
toast.success("Token已复制"); toast.success("Token已复制");
}; };
const formatFlowGB = (bytes: number) => { const formatFlowGB = formatTraffic;
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
};
const formatChainType = (chainType: number, hopInx: number) => { const formatChainType = (chainType: number, hopInx: number) => {
if (chainType === 1) { if (chainType === 1) {
@@ -741,17 +770,18 @@ export default function PanelSharingPage() {
}) })
} }
/> />
<Input <TrafficLimitField
description="0 表示不限流量" description="0 表示不限流量"
label="流量上限 (GB)" label="流量上限"
type="number" unit={shareUnit}
value={shareForm.maxBandwidth.toString()} value={shareForm.maxBandwidth.toString()}
onChange={(e) => onChange={(value, unit) => {
setShareForm({ setShareForm((prev) => ({
...shareForm, ...prev,
maxBandwidth: parseInt(e.target.value, 10) || 0, maxBandwidth: Number(value) || 0,
}) }));
} setShareUnit(unit);
}}
/> />
<Input <Input
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制" description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
@@ -826,17 +856,19 @@ export default function PanelSharingPage() {
} }
/> />
</div> </div>
<Input <TrafficLimitField
description="0 表示不限流量" description="0 表示不限流量"
label="流量上限 (GB)" label="流量上限"
type="number" unit={editUnit}
value={editForm.maxBandwidth.toString()} value={editForm.maxBandwidth.toString()}
onChange={(e) => onChange={(value, unit) => {
setEditForm({ setEditForm((prev) => ({
...editForm, ...prev,
maxBandwidth: parseInt(e.target.value, 10) || 0, maxBandwidth: Number(value) || 0,
}) }));
} setEditUnit(unit);
setEditBandwidthChanged(true);
}}
/> />
<Input <Input
description="留空或清除表示永久有效" description="留空或清除表示永久有效"
+85 -48
View File
@@ -71,23 +71,22 @@ import {
SearchIcon, SearchIcon,
} from "@/components/icons"; } from "@/components/icons";
import { PageLoadingState } from "@/components/page-state"; import { PageLoadingState } from "@/components/page-state";
import { TrafficLimitField } from "@/components/traffic-limit-field";
import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { removeItemsById, replaceItemById } from "@/utils/list-state"; import { removeItemsById, replaceItemById } from "@/utils/list-state";
import {
formatTraffic,
formatFlowLimit,
flowLimitBytes,
flowLimitMiB,
parseTrafficInput,
preferredTrafficUnit,
TRAFFIC_UNIT_MIB,
type TrafficUnit,
} from "@/utils/traffic";
// 工具函数 // 工具函数
const formatFlow = (value: number, unit: string = "bytes"): string => { const formatFlow = formatTraffic;
if (unit === "gb") {
return `${value} GB`;
} else {
if (value === 0) return "0 B";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`;
if (value < 1024 * 1024 * 1024)
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
};
const formatQuotaLimit = (value?: number): string => { const formatQuotaLimit = (value?: number): string => {
const limit = Number(value ?? 0); const limit = Number(value ?? 0);
@@ -96,7 +95,14 @@ const formatQuotaLimit = (value?: number): string => {
return "不限"; return "不限";
} }
return `${limit} GB`; return formatTraffic(limit * 1024 ** 3);
};
const trafficInputFor = (flowGB: number, flowMiB?: number) => {
const mib = flowLimitMiB(flowGB, flowMiB);
const unit = preferredTrafficUnit(mib);
return { value: String(mib / TRAFFIC_UNIT_MIB[unit]), unit };
}; };
const formatDate = (timestamp: number): string => { const formatDate = (timestamp: number): string => {
@@ -148,6 +154,7 @@ const normalizeUserItem = (item: Partial<User>): User => {
user: String(item.user ?? ""), user: String(item.user ?? ""),
status: Number(item.status ?? 0), status: Number(item.status ?? 0),
flow: Number(item.flow ?? 0), flow: Number(item.flow ?? 0),
flowMiB: Number(item.flowMiB ?? 0),
num: Number(item.num ?? 0), num: Number(item.num ?? 0),
expTime: item.expTime, expTime: item.expTime,
flowResetTime: item.flowResetTime ?? 0, flowResetTime: item.flowResetTime ?? 0,
@@ -172,6 +179,7 @@ const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
tunnelName: String(item.tunnelName ?? ""), tunnelName: String(item.tunnelName ?? ""),
status: Number(item.status ?? 0), status: Number(item.status ?? 0),
flow: Number(item.flow ?? 0), flow: Number(item.flow ?? 0),
flowMiB: Number(item.flowMiB ?? 0),
num: Number(item.num ?? 0), num: Number(item.num ?? 0),
expTime: Number(item.expTime ?? 0), expTime: Number(item.expTime ?? 0),
flowResetTime: Number(item.flowResetTime ?? 0), flowResetTime: Number(item.flowResetTime ?? 0),
@@ -223,6 +231,8 @@ export default function UserPage() {
maxConn: 0, maxConn: 0,
}); });
const [userFormLoading, setUserFormLoading] = useState(false); const [userFormLoading, setUserFormLoading] = useState(false);
const [userFlowInput, setUserFlowInput] = useState("1000");
const [userFlowUnit, setUserFlowUnit] = useState<TrafficUnit>("GB");
const [quotaResetLoading, setQuotaResetLoading] = useState(false); const [quotaResetLoading, setQuotaResetLoading] = useState(false);
const editingUser = useMemo( const editingUser = useMemo(
@@ -263,6 +273,8 @@ export default function UserPage() {
onClose: onEditTunnelModalClose, onClose: onEditTunnelModalClose,
} = useDisclosure(); } = useDisclosure();
const [editTunnelForm, setEditTunnelForm] = useState<UserTunnel | null>(null); const [editTunnelForm, setEditTunnelForm] = useState<UserTunnel | null>(null);
const [tunnelFlowInput, setTunnelFlowInput] = useState("");
const [tunnelFlowUnit, setTunnelFlowUnit] = useState<TrafficUnit>("GB");
const [editTunnelLoading, setEditTunnelLoading] = useState(false); const [editTunnelLoading, setEditTunnelLoading] = useState(false);
// 删除确认相关状态 // 删除确认相关状态
@@ -506,6 +518,8 @@ export default function UserPage() {
const handleAdd = () => { const handleAdd = () => {
setIsEdit(false); setIsEdit(false);
setUserFlowInput("1000");
setUserFlowUnit("GB");
setUserForm({ setUserForm({
user: "", user: "",
pwd: "", pwd: "",
@@ -524,6 +538,10 @@ export default function UserPage() {
const handleEdit = async (user: User) => { const handleEdit = async (user: User) => {
setIsEdit(true); setIsEdit(true);
const trafficInput = trafficInputFor(user.flow, user.flowMiB);
setUserFlowInput(trafficInput.value);
setUserFlowUnit(trafficInput.unit);
let currentGroupIds: number[] = []; let currentGroupIds: number[] = [];
try { try {
@@ -591,10 +609,21 @@ export default function UserPage() {
return; return;
} }
const flowMiB = parseTrafficInput(userFlowInput, userFlowUnit);
if (flowMiB === null) {
toast.error("请输入有效的流量限制,最小单位为 1 MB");
return;
}
setUserFormLoading(true); setUserFormLoading(true);
try { try {
const submitData: any = { const submitData: any = {
...userForm, ...userForm,
flow: Math.ceil(flowMiB / 1024),
flowMiB:
userFlowInput === "99999" && userFlowUnit === "GB" ? 0 : flowMiB,
expTime: userForm.expTime.getTime(), expTime: userForm.expTime.getTime(),
groupIds: userForm.groupIds ?? [], groupIds: userForm.groupIds ?? [],
}; };
@@ -756,6 +785,10 @@ export default function UserPage() {
}; };
const handleEditTunnel = (userTunnel: UserTunnel) => { const handleEditTunnel = (userTunnel: UserTunnel) => {
const trafficInput = trafficInputFor(userTunnel.flow, userTunnel.flowMiB);
setTunnelFlowInput(trafficInput.value);
setTunnelFlowUnit(trafficInput.unit);
setEditTunnelForm({ setEditTunnelForm({
...userTunnel, ...userTunnel,
speedId: normalizeSpeedId(userTunnel.speedId), speedId: normalizeSpeedId(userTunnel.speedId),
@@ -767,12 +800,24 @@ export default function UserPage() {
const handleUpdateTunnel = async () => { const handleUpdateTunnel = async () => {
if (!editTunnelForm) return; if (!editTunnelForm) return;
const flowMiB = parseTrafficInput(tunnelFlowInput, tunnelFlowUnit);
if (flowMiB === null) {
toast.error("请输入有效的流量限制,最小单位为 1 MB");
return;
}
const flow = Math.ceil(flowMiB / 1024);
const storedFlowMiB =
tunnelFlowInput === "99999" && tunnelFlowUnit === "GB" ? 0 : flowMiB;
setEditTunnelLoading(true); setEditTunnelLoading(true);
try { try {
const speedLimitAutoCleared = isMissingSpeedLimit(editTunnelForm.speedId); const speedLimitAutoCleared = isMissingSpeedLimit(editTunnelForm.speedId);
const response = await updateUserTunnel({ const response = await updateUserTunnel({
id: editTunnelForm.id, id: editTunnelForm.id,
flow: editTunnelForm.flow, flow,
flowMiB: storedFlowMiB,
num: editTunnelForm.num, num: editTunnelForm.num,
expTime: editTunnelForm.expTime, expTime: editTunnelForm.expTime,
flowResetTime: editTunnelForm.flowResetTime, flowResetTime: editTunnelForm.flowResetTime,
@@ -792,6 +837,8 @@ export default function UserPage() {
if (currentUser) { if (currentUser) {
const nextTunnel = normalizeUserTunnelItem({ const nextTunnel = normalizeUserTunnelItem({
...editTunnelForm, ...editTunnelForm,
flow,
flowMiB: storedFlowMiB,
speedId: normalizeSpeedId(editTunnelForm.speedId), speedId: normalizeSpeedId(editTunnelForm.speedId),
speedLimitName: speedLimitName:
normalizeSpeedId(editTunnelForm.speedId) !== null normalizeSpeedId(editTunnelForm.speedId) !== null
@@ -1148,7 +1195,7 @@ export default function UserPage() {
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-xs">
<span className="text-default-500">限制:</span> <span className="text-default-500">限制:</span>
<span className="text-default-700 font-medium whitespace-nowrap"> <span className="text-default-700 font-medium whitespace-nowrap">
{formatFlow(user.flow, "gb")} {formatFlowLimit(user.flow, user.flowMiB)}
</span> </span>
</div> </div>
</div> </div>
@@ -1258,9 +1305,9 @@ export default function UserPage() {
: null; : null;
const usedFlow = calculateUserTotalUsedFlow(user); const usedFlow = calculateUserTotalUsedFlow(user);
const flowPercent = const flowPercent =
user.flow > 0 user.flow > 0 && !(user.flow === 99999 && !user.flowMiB)
? Math.min( ? Math.min(
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100, (usedFlow / flowLimitBytes(user.flow, user.flowMiB)) * 100,
100, 100,
) )
: 0; : 0;
@@ -1316,7 +1363,7 @@ export default function UserPage() {
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-default-600">流量限制</span> <span className="text-default-600">流量限制</span>
<span className="font-medium text-xs"> <span className="font-medium text-xs">
{formatFlow(user.flow, "gb")} {formatFlowLimit(user.flow, user.flowMiB)}
</span> </span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
@@ -1504,20 +1551,14 @@ export default function UserPage() {
setUserForm((prev) => ({ ...prev, pwd: e.target.value })) setUserForm((prev) => ({ ...prev, pwd: e.target.value }))
} }
/> />
<Input <TrafficLimitField
isRequired isRequired
label="流量限制(GB)" label="流量限制"
max="99999" unit={userFlowUnit}
min="1" value={userFlowInput}
type="number" onChange={(value, unit) => {
value={userForm.flow.toString()} setUserFlowInput(value);
onChange={(e) => { setUserFlowUnit(unit);
const value = Math.min(
Math.max(Number(e.target.value) || 0, 1),
99999,
);
setUserForm((prev) => ({ ...prev, flow: value }));
}} }}
/> />
<Input <Input
@@ -1963,7 +2004,10 @@ export default function UserPage() {
<div className="flex justify-between text-small"> <div className="flex justify-between text-small">
<span className="text-gray-600">限制:</span> <span className="text-gray-600">限制:</span>
<span className="font-medium"> <span className="font-medium">
{formatFlow(userTunnel.flow, "gb")} {formatFlowLimit(
userTunnel.flow,
userTunnel.flowMiB,
)}
</span> </span>
</div> </div>
<div className="flex justify-between text-small"> <div className="flex justify-between text-small">
@@ -2083,21 +2127,14 @@ export default function UserPage() {
{editTunnelForm && ( {editTunnelForm && (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input <TrafficLimitField
label="流量限制(GB)" isRequired
max="99999" label="流量限制"
min="1" unit={tunnelFlowUnit}
type="number" value={tunnelFlowInput}
value={editTunnelForm.flow.toString()} onChange={(value, unit) => {
onChange={(e) => { setTunnelFlowInput(value);
const value = Math.min( setTunnelFlowUnit(unit);
Math.max(Number(e.target.value) || 0, 1),
99999,
);
setEditTunnelForm((prev) =>
prev ? { ...prev, flow: value } : null,
);
}} }}
/> />
+2
View File
@@ -12,6 +12,7 @@ export interface User {
pwd?: string; pwd?: string;
status: number; // 1-正常, 0-禁用 status: number; // 1-正常, 0-禁用
flow: number; // 流量限制(GB) flow: number; // 流量限制(GB)
flowMiB?: number; // 精确流量限制(MiB),0 表示沿用旧版 GB 字段
num: number; // 转发数量 num: number; // 转发数量
expTime?: number; // 过期时间戳 expTime?: number; // 过期时间戳
flowResetTime?: number; // 流量重置日期(1-31号) flowResetTime?: number; // 流量重置日期(1-31号)
@@ -56,6 +57,7 @@ export interface UserTunnel {
tunnelName: string; tunnelName: string;
status: number; // 1-正常, 0-禁用 status: number; // 1-正常, 0-禁用
flow: number; // 流量限制(GB) flow: number; // 流量限制(GB)
flowMiB?: number;
num: number; // 转发数量 num: number; // 转发数量
expTime: number; // 过期时间戳 expTime: number; // 过期时间戳
flowResetTime: number; flowResetTime: number;
+72
View File
@@ -0,0 +1,72 @@
export type TrafficUnit = "MB" | "GB" | "TB" | "PB";
export const MIB = 1024 * 1024;
export const GIB = 1024 * MIB;
export const TRAFFIC_UNIT_MIB: Record<TrafficUnit, number> = {
MB: 1,
GB: 1024,
TB: 1024 ** 2,
PB: 1024 ** 3,
};
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
export function formatTraffic(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? Math.floor(value) : value.toFixed(2)} ${BYTE_UNITS[unit]}`;
}
export function flowLimitMiB(flowGB: number, flowMiB?: number): number {
return flowMiB && flowMiB > 0 ? flowMiB : flowGB * 1024;
}
export function flowLimitBytes(flowGB: number, flowMiB?: number): number {
return flowLimitMiB(flowGB, flowMiB) * MIB;
}
export function formatFlowLimit(flowGB: number, flowMiB?: number): string {
if (flowGB === 99999 && !flowMiB) return "无限制";
return formatTraffic(flowLimitBytes(flowGB, flowMiB));
}
export function preferredTrafficUnit(mib: number): TrafficUnit {
if (mib <= 0) return "GB";
if (mib > 0 && mib % TRAFFIC_UNIT_MIB.PB === 0) return "PB";
if (mib > 0 && mib % TRAFFIC_UNIT_MIB.TB === 0) return "TB";
if (mib > 0 && mib % TRAFFIC_UNIT_MIB.GB === 0) return "GB";
return "MB";
}
export function parseTrafficInput(
value: string,
unit: TrafficUnit,
): number | null {
const amount = Number(value);
const mib = amount * TRAFFIC_UNIT_MIB[unit];
if (
!value.trim() ||
!Number.isFinite(amount) ||
amount <= 0 ||
!Number.isSafeInteger(mib)
) {
return null;
}
// Backend stores bytes as int64. Keep the converted value within that range.
if (mib > 8_796_093_022_207) return null;
return mib;
}