mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 02:36:37 +08:00
feat(nodes): add node metadata and expiry reminders (#293)
## Summary - 为节点新增备注、标签、到期时间字段,并贯通后端存储、导入导出与前端 API - 节点管理页支持编辑、搜索、筛选并高亮即将到期或已过期节点 - Dashboard 为管理员新增节点到期提醒卡片,集中展示 7 天内到期和已过期节点 ## Changes ### Backend - `node/create` 和 `node/update` 支持 `remark`、`tags`、`expiryTime` - 节点列表接口返回新增字段 - 节点导入导出保留备注、标签与到期时间 ### Frontend - 节点页面新增备注、标签、到期时间表单项 - 节点列表支持按备注/标签搜索,并支持到期状态筛选 - 节点卡片展示备注、标签、到期时间以及过期提醒样式 - Dashboard 管理员视图新增节点到期提醒模块 ## Plans - `plans/021-node-remarks-tags-expiry.md` - `plans/022-node-expiry-highlights-dashboard-reminders.md` ## Issue Closes #246
This commit is contained in:
@@ -53,6 +53,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
|||||||
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
|
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
|
||||||
|
|
||||||
## CONVENTIONS
|
## CONVENTIONS
|
||||||
|
- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch.
|
||||||
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||||
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||||
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
defaultString(asString(req["port"]), "1000-65535"),
|
defaultString(asString(req["port"]), "1000-65535"),
|
||||||
nullableText(asString(req["interfaceName"])),
|
nullableText(asString(req["interfaceName"])),
|
||||||
nullableText(""),
|
nullableText(""),
|
||||||
|
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||||
|
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||||
|
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||||
asInt(req["http"], 0),
|
asInt(req["http"], 0),
|
||||||
asInt(req["tls"], 0),
|
asInt(req["tls"], 0),
|
||||||
asInt(req["socks"], 0),
|
asInt(req["socks"], 0),
|
||||||
@@ -326,6 +329,9 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
defaultString(asString(req["port"]), "1000-65535"),
|
defaultString(asString(req["port"]), "1000-65535"),
|
||||||
nullableText(asString(req["interfaceName"])),
|
nullableText(asString(req["interfaceName"])),
|
||||||
nullableText(asString(req["extraIPs"])),
|
nullableText(asString(req["extraIPs"])),
|
||||||
|
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||||
|
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||||
|
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||||
newHTTP,
|
newHTTP,
|
||||||
newTLS,
|
newTLS,
|
||||||
newSocks,
|
newSocks,
|
||||||
@@ -3534,6 +3540,13 @@ func nullableText(s string) interface{} {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullableUnixMilli(v int64) interface{} {
|
||||||
|
if v <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func nullableInt(v *int64) interface{} {
|
func nullableInt(v *int64) interface{} {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ func (ForwardPort) TableName() string { return "forward_port" }
|
|||||||
type Node struct {
|
type Node struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `gorm:"type:varchar(100);not null"`
|
Name string `gorm:"type:varchar(100);not null"`
|
||||||
|
Remark sql.NullString `gorm:"column:remark;type:text"`
|
||||||
|
Tags sql.NullString `gorm:"column:tags;type:text"`
|
||||||
|
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
|
||||||
Secret string `gorm:"type:varchar(100);not null"`
|
Secret string `gorm:"type:varchar(100);not null"`
|
||||||
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
||||||
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
||||||
@@ -336,6 +339,9 @@ type UserBackup struct {
|
|||||||
type NodeBackup struct {
|
type NodeBackup struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Remark string `json:"remark,omitempty"`
|
||||||
|
Tags string `json:"tags,omitempty"`
|
||||||
|
ExpiryTime int64 `json:"expiryTime,omitempty"`
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
ServerIP string `json:"serverIp"`
|
ServerIP string `json:"serverIp"`
|
||||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||||
|
|||||||
@@ -634,7 +634,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
|||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
items = append(items, map[string]interface{}{
|
items = append(items, map[string]interface{}{
|
||||||
"id": n.ID, "inx": n.Inx, "name": n.Name,
|
"id": n.ID, "inx": n.Inx, "name": n.Name,
|
||||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
"remark": nullableString(n.Remark),
|
||||||
|
"tags": nullableString(n.Tags),
|
||||||
|
"expiryTime": nullableInt64(n.ExpiryTime),
|
||||||
|
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||||
"serverIpV4": nullableString(n.ServerIPV4),
|
"serverIpV4": nullableString(n.ServerIPV4),
|
||||||
"serverIpV6": nullableString(n.ServerIPV6),
|
"serverIpV6": nullableString(n.ServerIPV6),
|
||||||
"extraIPs": nullableString(n.ExtraIPs),
|
"extraIPs": nullableString(n.ExtraIPs),
|
||||||
@@ -700,25 +703,26 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fwdRow struct {
|
type fwdRow struct {
|
||||||
ID int64
|
ID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
UserName string
|
UserName string
|
||||||
Name string
|
Name string
|
||||||
TunnelID int64
|
TunnelID int64
|
||||||
TunnelName string
|
TunnelName string
|
||||||
RemoteAddr string
|
TrafficRatio float64
|
||||||
Strategy string
|
RemoteAddr string
|
||||||
InFlow int64
|
Strategy string
|
||||||
OutFlow int64
|
InFlow int64
|
||||||
CreatedTime int64
|
OutFlow int64
|
||||||
Status int
|
CreatedTime int64
|
||||||
Inx int
|
Status int
|
||||||
SpeedID sql.NullInt64
|
Inx int
|
||||||
|
SpeedID sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []fwdRow
|
var rows []fwdRow
|
||||||
err := r.db.Model(&model.Forward{}).
|
err := r.db.Model(&model.Forward{}).
|
||||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
|
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
|
||||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||||
Order("forward.inx ASC, forward.id ASC").
|
Order("forward.inx ASC, forward.id ASC").
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
@@ -735,7 +739,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
|||||||
item := map[string]interface{}{
|
item := map[string]interface{}{
|
||||||
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
|
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
|
||||||
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
|
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
|
||||||
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
|
"tunnelTrafficRatio": row.TrafficRatio,
|
||||||
|
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
|
||||||
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
|
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
|
||||||
"inFlow": row.InFlow, "outFlow": row.OutFlow,
|
"inFlow": row.InFlow, "outFlow": row.OutFlow,
|
||||||
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
|
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
|
||||||
@@ -1814,11 +1819,15 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) {
|
|||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
b := model.NodeBackup{
|
b := model.NodeBackup{
|
||||||
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
|
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
|
||||||
|
Remark: n.Remark.String, Tags: n.Tags.String,
|
||||||
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
|
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
|
||||||
CreatedTime: n.CreatedTime, Status: n.Status,
|
CreatedTime: n.CreatedTime, Status: n.Status,
|
||||||
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
||||||
Inx: n.Inx, IsRemote: n.IsRemote,
|
Inx: n.Inx, IsRemote: n.IsRemote,
|
||||||
}
|
}
|
||||||
|
if n.ExpiryTime.Valid {
|
||||||
|
b.ExpiryTime = n.ExpiryTime.Int64
|
||||||
|
}
|
||||||
if n.UpdatedTime.Valid {
|
if n.UpdatedTime.Valid {
|
||||||
b.UpdatedTime = n.UpdatedTime.Int64
|
b.UpdatedTime = n.UpdatedTime.Int64
|
||||||
}
|
}
|
||||||
@@ -2168,6 +2177,9 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
|||||||
item := model.Node{
|
item := model.Node{
|
||||||
ID: n.ID,
|
ID: n.ID,
|
||||||
Name: n.Name,
|
Name: n.Name,
|
||||||
|
Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""},
|
||||||
|
Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""},
|
||||||
|
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
|
||||||
Secret: n.Secret,
|
Secret: n.Secret,
|
||||||
ServerIP: n.ServerIP,
|
ServerIP: n.ServerIP,
|
||||||
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
|
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
|
||||||
@@ -2192,7 +2204,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, 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{
|
||||||
"name", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
|
"name", "remark", "tags", "expiry_time", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
|
||||||
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
|
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
|
||||||
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
|
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -196,12 +196,15 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
|
|||||||
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) 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")
|
||||||
}
|
}
|
||||||
node := model.Node{
|
node := model.Node{
|
||||||
Name: name,
|
Name: name,
|
||||||
|
Remark: nullStringFromInterface(remark),
|
||||||
|
Tags: nullStringFromInterface(tags),
|
||||||
|
ExpiryTime: nullInt64FromInterface(expiryTime),
|
||||||
Secret: secret,
|
Secret: secret,
|
||||||
ServerIP: serverIP,
|
ServerIP: serverIP,
|
||||||
ServerIPV4: nullStringFromInterface(serverIPV4),
|
ServerIPV4: nullStringFromInterface(serverIPV4),
|
||||||
@@ -239,7 +242,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
|
|||||||
return node.Status, node.HTTP, node.TLS, node.Socks, nil
|
return node.Status, node.HTTP, node.TLS, node.Socks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now 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")
|
||||||
}
|
}
|
||||||
@@ -247,6 +250,9 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
|
|||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"name": name,
|
"name": name,
|
||||||
|
"remark": nullStringFromInterface(remark),
|
||||||
|
"tags": nullStringFromInterface(tags),
|
||||||
|
"expiry_time": nullInt64FromInterface(expiryTime),
|
||||||
"server_ip": serverIP,
|
"server_ip": serverIP,
|
||||||
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
||||||
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
|||||||
if err := repo.DB().Exec(`
|
if err := repo.DB().Exec(`
|
||||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
`, "contract-tunnel", 2.5, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
t.Fatalf("insert tunnel: %v", err)
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
}
|
}
|
||||||
tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
|
tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
|
||||||
@@ -118,6 +118,13 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
|||||||
if got := int64(idFloat); got != userForwardID {
|
if got := int64(idFloat); got != userForwardID {
|
||||||
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
|
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
|
||||||
}
|
}
|
||||||
|
ratioFloat, ok := item["tunnelTrafficRatio"].(float64)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected tunnelTrafficRatio to be float64, got %T", item["tunnelTrafficRatio"])
|
||||||
|
}
|
||||||
|
if ratioFloat != 2.5 {
|
||||||
|
t.Fatalf("expected tunnelTrafficRatio 2.5, got %v", ratioFloat)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("forward diagnose returns structured payload", func(t *testing.T) {
|
t.Run("forward diagnose returns structured payload", func(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# 020 AJAX No-refresh UX
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
- Implement issue `#276` as a focused frontend UX improvement initiative, not a full data-layer rewrite.
|
||||||
|
- Keep the existing `axios + local React state + custom hooks` architecture, and extend it with polling, realtime hardening, and local state patching where it improves responsiveness.
|
||||||
|
- Deliver the work in phases so the highest-value improvements ship first: dashboard auto-refresh and node realtime resilience, then local list updates after mutations, then batch progress and search/filter polish.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Do not introduce `@tanstack/react-query`, SWR, or other new frontend data libraries for this issue.
|
||||||
|
- Do not rewrite page architecture, routing, or modal flows that already submit asynchronously without browser reloads.
|
||||||
|
- Do not require backend changes unless a batch-progress requirement cannot be met with the current API surface.
|
||||||
|
- Do not change the raw JWT auth convention used by `vite-frontend/src/api/network.ts`.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- `vite-frontend/src/pages/node/use-node-realtime.ts` and `vite-frontend/src/pages/node.tsx` already provide websocket-driven node status, system info, and upgrade progress updates.
|
||||||
|
- `vite-frontend/src/pages/forward.tsx`, `vite-frontend/src/pages/tunnel.tsx`, `vite-frontend/src/pages/user.tsx`, and `vite-frontend/src/pages/node.tsx` already submit forms asynchronously, so the main remaining gap is consistency of post-submit local refresh behavior.
|
||||||
|
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` currently fetches dashboard data only once on mount, so traffic charts and counters do not auto-refresh.
|
||||||
|
- Several mutation handlers still rely on page-level reload functions such as `loadData()`, `loadUsers()`, or `loadNodes()` instead of patching only the changed records.
|
||||||
|
- Batch progress UI exists for node upgrade but not for other batch actions such as forward and tunnel operations.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- Prefer local state patching after successful mutations when the changed record set is known.
|
||||||
|
- Prefer targeted refetches over full-page refetches when the server is the source of truth for a small dependent dataset.
|
||||||
|
- Use polling only where realtime transport does not already exist.
|
||||||
|
- Pause or reduce background refresh work when the page is hidden to avoid unnecessary traffic.
|
||||||
|
- Keep UI feedback explicit: loading states, toast feedback, and visible progress for long-running batch actions.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [x] Refactor dashboard data loading into reusable refresh callbacks in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`.
|
||||||
|
- [x] Add dashboard traffic polling with visibility-aware pause/resume and safe notification deduplication.
|
||||||
|
- [x] Harden node realtime reconnection behavior in `vite-frontend/src/pages/node/use-node-realtime.ts` and define a fallback refresh path if websocket recovery fails.
|
||||||
|
- [x] Add shared local-list patch helpers for replace/remove/upsert patterns used by page-level mutation handlers.
|
||||||
|
- [x] Convert forward create/edit/delete/service-toggle flows in `vite-frontend/src/pages/forward.tsx` from whole-page refetches to local or targeted updates where safe.
|
||||||
|
- [x] Convert tunnel create/edit/delete flows in `vite-frontend/src/pages/tunnel.tsx` from whole-page refetches to local or targeted updates where safe.
|
||||||
|
- [x] Convert user create/edit/delete and user-tunnel permission mutation flows in `vite-frontend/src/pages/user.tsx` to local or targeted updates where safe.
|
||||||
|
- [x] Extend batch action UX to show visible progress or staged feedback for forward and tunnel batch operations.
|
||||||
|
- [x] Normalize search/filter behavior and document where client-side instant filtering is appropriate versus where server-side pagination must remain authoritative.
|
||||||
|
- [ ] Run focused frontend verification and record the result in this plan after implementation.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1 - Dashboard auto-refresh and node realtime resilience
|
||||||
|
|
||||||
|
#### 1. Dashboard traffic/statistics auto-refresh
|
||||||
|
|
||||||
|
- Extract `loadPackageData()` and `loadAnnouncement()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` into stable callbacks so the hook can refresh data without re-running the whole mount sequence.
|
||||||
|
- Add a 5-second polling loop for package, flow, and chart data returned by `getUserPackageInfo()`.
|
||||||
|
- Keep announcement loading low-frequency or first-load only unless the API contract clearly expects live updates.
|
||||||
|
- Pause polling when `document.visibilityState !== "visible"`, then trigger an immediate refresh when the tab becomes visible again.
|
||||||
|
- Preserve current loading UX for first load, but use a silent refresh path for polling so the page does not flicker.
|
||||||
|
|
||||||
|
#### 2. Dashboard notification safety
|
||||||
|
|
||||||
|
- Audit `checkExpirationNotifications()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` so polling does not repeatedly emit expiration warnings.
|
||||||
|
- Continue using notification deduplication, but base it on stable expiration identifiers rather than every poll cycle.
|
||||||
|
- Ensure refreshes that only change traffic counters do not retrigger expiry toasts.
|
||||||
|
|
||||||
|
#### 3. Node realtime hardening
|
||||||
|
|
||||||
|
- Review `vite-frontend/src/pages/node/use-node-realtime.ts` reconnect logic, which currently stops after a fixed retry budget.
|
||||||
|
- Replace the hard stop with controlled backoff reconnect behavior, or explicitly trigger a degraded polling fallback once retry exhaustion is reached.
|
||||||
|
- If a fallback list refresh is introduced, merge incoming node metadata with existing `systemInfo`, `connectionStatus`, and upgrade-progress state so live metrics are not wiped during recovery.
|
||||||
|
- Keep the existing offline debounce behavior in `vite-frontend/src/pages/node/use-node-offline-timers.ts`.
|
||||||
|
|
||||||
|
### Phase 2 - Local mutation updates and partial refreshes
|
||||||
|
|
||||||
|
#### 4. Shared list-patching helpers
|
||||||
|
|
||||||
|
- Add small reusable helpers for common state operations such as:
|
||||||
|
- replace one item by `id`
|
||||||
|
- remove one or many items by `id`
|
||||||
|
- upsert a created or updated item into an ordered list
|
||||||
|
- preserve derived UI-only fields during server payload merges
|
||||||
|
- Keep these helpers local to the frontend codebase and avoid introducing a generic state-management abstraction.
|
||||||
|
|
||||||
|
#### 5. Forward page partial refresh conversion
|
||||||
|
|
||||||
|
- Target `vite-frontend/src/pages/forward.tsx` mutation handlers first because the page already contains some optimistic/local patterns.
|
||||||
|
- Preserve the current local behavior for service toggles, but review rollback handling so final UI state matches backend truth after success or failure.
|
||||||
|
- Change create/edit/delete flows to patch `forwards` state directly when the response payload is sufficient.
|
||||||
|
- Use targeted refetches only when an operation changes dependent datasets that are not reliably derivable from the local page state.
|
||||||
|
- Re-check grouped ordering, collapsed-state persistence, and selected-row state after local mutations.
|
||||||
|
|
||||||
|
#### 6. Tunnel page partial refresh conversion
|
||||||
|
|
||||||
|
- Update `vite-frontend/src/pages/tunnel.tsx` so create/edit/delete mutate `tunnels` state directly instead of always calling `loadData()`.
|
||||||
|
- Keep node reference data refresh separate from tunnel list refresh so a tunnel mutation does not force a full page data reload.
|
||||||
|
- Preserve existing drag-sort behavior and ensure local patching keeps `inx` and stored order consistent.
|
||||||
|
|
||||||
|
#### 7. User page partial refresh conversion
|
||||||
|
|
||||||
|
- Update `vite-frontend/src/pages/user.tsx` so create/edit/delete patch the `users` list when the current page can be updated safely.
|
||||||
|
- Update user-tunnel permission flows to patch `userTunnels` directly after assign, edit, remove, and flow-reset operations.
|
||||||
|
- Respect server-side pagination semantics for the user list; if the server response does not provide enough data for a safe local patch, use a targeted page refetch rather than a full multi-dataset refresh.
|
||||||
|
- Keep current modal and toast behavior unchanged unless the local update path exposes stale-state issues.
|
||||||
|
|
||||||
|
### Phase 3 - Batch progress UX and search/filter polish
|
||||||
|
|
||||||
|
#### 8. Batch progress UX
|
||||||
|
|
||||||
|
- Use the node upgrade progress model in `vite-frontend/src/pages/node.tsx` as the UI reference for long-running operations.
|
||||||
|
- Review `vite-frontend/src/pages/forward/batch-actions.ts` and tunnel batch handlers to determine whether current APIs expose enough intermediate state for real progress.
|
||||||
|
- If only final summary APIs are available, implement staged client-side progress feedback such as `processing X/Y`, current action label, success count, and failure count.
|
||||||
|
- If the UX requirement cannot be met without backend support, document the missing backend contract and split the work into frontend and backend follow-ups.
|
||||||
|
|
||||||
|
#### 9. Search and filter responsiveness
|
||||||
|
|
||||||
|
- Preserve instant client-side filtering on pages that already hold the authoritative dataset locally, including node, tunnel, and forward pages.
|
||||||
|
- Audit the user page separately because it depends on server-side pagination and keyword search.
|
||||||
|
- If user-page instant filtering is desired, choose one of two explicit strategies:
|
||||||
|
- keep server-side pagination authoritative and add debounce for keyword-triggered requests, or
|
||||||
|
- load a larger local dataset only if product requirements accept the cost.
|
||||||
|
- Do not silently mix partial client filtering with incomplete paginated datasets.
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
- Repeated dashboard polling may spam expiry toasts.
|
||||||
|
- Mitigation: deduplicate notifications based on expiration identity and only emit on meaningful state changes.
|
||||||
|
- Node recovery refreshes may wipe websocket-derived metrics.
|
||||||
|
- Mitigation: merge fetched node metadata into existing live state instead of replacing the whole record blindly.
|
||||||
|
- Local mutation patching may desynchronize grouped, sorted, or selected views.
|
||||||
|
- Mitigation: patch canonical source arrays first, then recompute derived memoized groupings from state.
|
||||||
|
- Batch APIs may not expose progress details.
|
||||||
|
- Mitigation: implement client-side staged progress where possible and document backend gaps where not.
|
||||||
|
- User-page local updates may conflict with pagination semantics.
|
||||||
|
- Mitigation: prefer targeted page refetch over unsafe optimistic filtering or cross-page list mutation.
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
- Dashboard:
|
||||||
|
- Open `dashboard` and confirm traffic counters and chart data refresh at least once every 5 seconds without manual reload.
|
||||||
|
- Confirm hidden-tab pause and visible-tab immediate refresh behavior.
|
||||||
|
- Confirm expiry toasts do not repeat on every polling cycle.
|
||||||
|
- Nodes:
|
||||||
|
- Confirm websocket-driven online/offline transitions still work.
|
||||||
|
- Simulate websocket interruption and verify reconnect or fallback refresh behavior.
|
||||||
|
- Confirm recovery does not clear existing live metrics unexpectedly.
|
||||||
|
- Forwards, tunnels, users:
|
||||||
|
- Create, edit, delete, enable, disable, and reset flows without browser reload.
|
||||||
|
- Confirm the affected rows update immediately and other unrelated rows stay stable.
|
||||||
|
- Confirm selection state, ordering, and modal close behavior remain correct after local patching.
|
||||||
|
- Batch actions:
|
||||||
|
- Confirm visible progress or staged status feedback exists during long-running operations.
|
||||||
|
- Confirm success and failure summaries remain accurate after completion.
|
||||||
|
- Build:
|
||||||
|
- Run `cd vite-frontend && npm run build`.
|
||||||
|
|
||||||
|
## Rollout Notes
|
||||||
|
|
||||||
|
- Ship Phase 1 first because it matches the issue approval priority and provides the clearest user-visible gain.
|
||||||
|
- Keep each phase in reviewable commits so regressions in local list patching can be isolated quickly.
|
||||||
|
- If backend support becomes necessary for real batch progress, land the frontend scaffolding separately and track the backend dependency explicitly.
|
||||||
|
|
||||||
|
## Test Record
|
||||||
|
|
||||||
|
- Command: `cd vite-frontend && npm install`
|
||||||
|
- Result: passed.
|
||||||
|
- Command: `cd vite-frontend && npm run build`
|
||||||
|
- Result: passed.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Node Remarks, Tags, and Expiry Plan
|
||||||
|
|
||||||
|
- [x] Review issue #246 and inspect current node backend/frontend flow
|
||||||
|
- [x] Extend node persistence and API payloads with remark, tags, and expiry fields
|
||||||
|
- [x] Update node management UI to edit, display, and search the new metadata
|
||||||
|
- [x] Verify the backend and frontend still build successfully
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Node Expiry Highlights And Dashboard Reminders Plan
|
||||||
|
|
||||||
|
- [x] Review current node page and dashboard data flow for expiry-related hooks
|
||||||
|
- [x] Add node expiry status helpers plus expiring-soon filter/highlight in node management
|
||||||
|
- [x] Load node expiry data on the dashboard for admins and render reminder card
|
||||||
|
- [x] Verify frontend build and mark the plan complete
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Forward Page Tunnel Traffic Ratio Plan
|
||||||
|
|
||||||
|
- [x] Review `/forward/list` data flow and rule page render points for tunnel ratio support
|
||||||
|
- [x] Extend backend forward list payload with tunnel traffic ratio and cover it with a contract test
|
||||||
|
- [x] Update forward page types, mapping, grouped metadata, and visible ratio UI across list modes
|
||||||
|
- [x] Verify targeted backend tests and frontend build, then mark the plan complete
|
||||||
@@ -65,6 +65,8 @@ export const getUserPackageInfo = () =>
|
|||||||
export const createNode = (data: NodeMutationPayload) =>
|
export const createNode = (data: NodeMutationPayload) =>
|
||||||
Network.post("/node/create", data);
|
Network.post("/node/create", data);
|
||||||
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
||||||
|
export const getDashboardNodeExpiryList = () =>
|
||||||
|
Network.post<NodeApiItem[]>("/node/list", {});
|
||||||
export const updateNode = (data: NodeMutationPayload) =>
|
export const updateNode = (data: NodeMutationPayload) =>
|
||||||
Network.post("/node/update", data);
|
Network.post("/node/update", data);
|
||||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ export interface NodeApiItem {
|
|||||||
name: string;
|
name: string;
|
||||||
status: number;
|
status: number;
|
||||||
inx?: number;
|
inx?: number;
|
||||||
|
remark?: string;
|
||||||
|
tags?: string;
|
||||||
|
expiryTime?: number;
|
||||||
syncError?: string;
|
syncError?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -44,6 +47,7 @@ export interface ForwardApiItem {
|
|||||||
name: string;
|
name: string;
|
||||||
status: number;
|
status: number;
|
||||||
tunnelName?: string;
|
tunnelName?: string;
|
||||||
|
tunnelTrafficRatio?: number;
|
||||||
inIp?: string;
|
inIp?: string;
|
||||||
inPort?: number;
|
inPort?: number;
|
||||||
remoteAddr?: string;
|
remoteAddr?: string;
|
||||||
@@ -214,6 +218,9 @@ export interface NodeMutationPayload {
|
|||||||
name?: string;
|
name?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
inx?: number;
|
inx?: number;
|
||||||
|
remark?: string;
|
||||||
|
tags?: string;
|
||||||
|
expiryTime?: number;
|
||||||
serverIp?: string;
|
serverIp?: string;
|
||||||
serverIpV4?: string;
|
serverIpV4?: string;
|
||||||
serverIpV6?: string;
|
serverIpV6?: string;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MetricCard } from "@/pages/dashboard/components/metric-card";
|
|||||||
import {
|
import {
|
||||||
useDashboardData,
|
useDashboardData,
|
||||||
type DashboardForward as Forward,
|
type DashboardForward as Forward,
|
||||||
|
type DashboardNodeExpiryItem,
|
||||||
type DashboardUserTunnel as UserTunnel,
|
type DashboardUserTunnel as UserTunnel,
|
||||||
} from "@/pages/dashboard/use-dashboard-data";
|
} from "@/pages/dashboard/use-dashboard-data";
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export default function DashboardPage() {
|
|||||||
userTunnels,
|
userTunnels,
|
||||||
forwardList,
|
forwardList,
|
||||||
statisticsFlows,
|
statisticsFlows,
|
||||||
|
nodeExpiryReminders,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
announcement,
|
announcement,
|
||||||
} = useDashboardData();
|
} = useDashboardData();
|
||||||
@@ -70,6 +72,103 @@ export default function DashboardPage() {
|
|||||||
return value.toString();
|
return value.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getNodeExpiryStatus = (expiryTime?: number) => {
|
||||||
|
if (!expiryTime) {
|
||||||
|
return {
|
||||||
|
label: "永久有效",
|
||||||
|
badgeClassName:
|
||||||
|
"bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = expiryTime - Date.now();
|
||||||
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays <= 0) {
|
||||||
|
return {
|
||||||
|
label: "已过期",
|
||||||
|
badgeClassName:
|
||||||
|
"bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffDays === 1) {
|
||||||
|
return {
|
||||||
|
label: "明天到期",
|
||||||
|
badgeClassName:
|
||||||
|
"bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${diffDays}天后到期`,
|
||||||
|
badgeClassName:
|
||||||
|
diffDays <= 7
|
||||||
|
? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300"
|
||||||
|
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNodeTags = (tags?: string) => {
|
||||||
|
if (!tags) return [];
|
||||||
|
return tags
|
||||||
|
.split(",")
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 3);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
|
||||||
|
const expiryStatus = getNodeExpiryStatus(node.expiryTime);
|
||||||
|
const tags = parseNodeTags(node.tags);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 via-white to-orange-50 p-4 shadow-sm dark:border-amber-500/20 dark:from-amber-950/20 dark:via-background dark:to-orange-950/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-foreground truncate">
|
||||||
|
{node.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-default-500">节点 ID: {node.id}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${expiryStatus.badgeClassName}`}
|
||||||
|
>
|
||||||
|
{expiryStatus.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
|
||||||
|
{node.expiryTime
|
||||||
|
? new Date(node.expiryTime).toLocaleString()
|
||||||
|
: "未设置到期时间"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.remark?.trim() && (
|
||||||
|
<p className="mt-3 line-clamp-2 text-xs leading-5 text-default-600 dark:text-default-400">
|
||||||
|
{node.remark.trim()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={`${node.id}-${tag}`}
|
||||||
|
className="rounded-full bg-white/80 px-2 py-1 text-[11px] text-default-600 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-default-300 dark:ring-amber-500/20"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 处理24小时流量统计数据
|
// 处理24小时流量统计数据
|
||||||
const processFlowChartData = () => {
|
const processFlowChartData = () => {
|
||||||
// 生成最近24小时的时间数组(从当前小时往前推24小时)
|
// 生成最近24小时的时间数组(从当前小时往前推24小时)
|
||||||
@@ -661,6 +760,52 @@ export default function DashboardPage() {
|
|||||||
statisticsFlowsCount={statisticsFlows.length}
|
statisticsFlowsCount={statisticsFlows.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isAdmin && nodeExpiryReminders.length > 0 && (
|
||||||
|
<Card className="mb-6 lg:mb-8 border border-amber-200/80 bg-gradient-to-br from-amber-50/90 via-background to-orange-50/70 shadow-md dark:border-amber-500/20 dark:from-amber-950/10 dark:to-orange-950/10">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.981-1.742 2.981H4.42c-1.53 0-2.492-1.647-1.743-2.98l5.58-9.92zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-7a1 1 0 00-1 1v3a1 1 0 102 0V7a1 1 0 00-1-1z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||||
|
节点到期提醒
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-default-500">
|
||||||
|
展示 7 天内到期或已过期的节点,便于提前续费或清理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex w-fit items-center rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-amber-300 dark:ring-amber-500/20">
|
||||||
|
{nodeExpiryReminders.length} 个提醒
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0">
|
||||||
|
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||||
|
{nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)}
|
||||||
|
</div>
|
||||||
|
{nodeExpiryReminders.length > 6 && (
|
||||||
|
<p className="mt-4 text-xs text-default-500">
|
||||||
|
还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 隧道权限 - 管理员不显示 */}
|
{/* 隧道权限 - 管理员不显示 */}
|
||||||
{!isAdmin && (
|
{!isAdmin && (
|
||||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { ForwardApiItem } from "@/api/types";
|
import type { ForwardApiItem, NodeApiItem } from "@/api/types";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAnnouncement,
|
getAnnouncement,
|
||||||
|
getDashboardNodeExpiryList,
|
||||||
getUserPackageInfo,
|
getUserPackageInfo,
|
||||||
type AnnouncementData,
|
type AnnouncementData,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
@@ -52,12 +53,38 @@ export interface DashboardStatisticsFlow {
|
|||||||
time: string;
|
time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardNodeExpiryItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
remark?: string;
|
||||||
|
tags?: string;
|
||||||
|
expiryTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DASHBOARD_POLL_INTERVAL_MS = 5000;
|
||||||
|
const EXPIRATION_NOTIFICATION_STORAGE_KEY =
|
||||||
|
"dashboard:last-expiration-notification";
|
||||||
|
|
||||||
|
const buildExpirationNotificationKey = (
|
||||||
|
userInfo: DashboardUserInfo,
|
||||||
|
tunnels: DashboardUserTunnel[],
|
||||||
|
) => {
|
||||||
|
const userExpTime = userInfo.expTime ?? "permanent";
|
||||||
|
const tunnelExpirationKey = [...tunnels]
|
||||||
|
.map((tunnel) => `${tunnel.tunnelId}:${tunnel.expTime ?? "permanent"}`)
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
|
||||||
|
return `user:${userExpTime};tunnels:${tunnelExpirationKey}`;
|
||||||
|
};
|
||||||
|
|
||||||
interface DashboardDataState {
|
interface DashboardDataState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
userInfo: DashboardUserInfo;
|
userInfo: DashboardUserInfo;
|
||||||
userTunnels: DashboardUserTunnel[];
|
userTunnels: DashboardUserTunnel[];
|
||||||
forwardList: DashboardForward[];
|
forwardList: DashboardForward[];
|
||||||
statisticsFlows: DashboardStatisticsFlow[];
|
statisticsFlows: DashboardStatisticsFlow[];
|
||||||
|
nodeExpiryReminders: DashboardNodeExpiryItem[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
announcement: AnnouncementData | null;
|
announcement: AnnouncementData | null;
|
||||||
}
|
}
|
||||||
@@ -66,8 +93,10 @@ const checkExpirationNotifications = (
|
|||||||
userInfo: DashboardUserInfo,
|
userInfo: DashboardUserInfo,
|
||||||
tunnels: DashboardUserTunnel[],
|
tunnels: DashboardUserTunnel[],
|
||||||
) => {
|
) => {
|
||||||
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
|
const notificationKey = buildExpirationNotificationKey(userInfo, tunnels);
|
||||||
const lastNotified = localStorage.getItem("lastNotified");
|
const lastNotified = localStorage.getItem(
|
||||||
|
EXPIRATION_NOTIFICATION_STORAGE_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
if (lastNotified === notificationKey) {
|
if (lastNotified === notificationKey) {
|
||||||
return;
|
return;
|
||||||
@@ -148,7 +177,7 @@ const checkExpirationNotifications = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (hasNotification) {
|
if (hasNotification) {
|
||||||
localStorage.setItem("lastNotified", notificationKey);
|
localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +203,28 @@ const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const warningWindowMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
return (items || [])
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || "",
|
||||||
|
remark: typeof item.remark === "string" ? item.remark : "",
|
||||||
|
tags: typeof item.tags === "string" ? item.tags : "",
|
||||||
|
expiryTime:
|
||||||
|
typeof item.expiryTime === "number" && item.expiryTime > 0
|
||||||
|
? item.expiryTime
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
.filter((item) => {
|
||||||
|
if (!item.expiryTime) return false;
|
||||||
|
return item.expiryTime <= now + warningWindowMs;
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.expiryTime || 0) - (b.expiryTime || 0));
|
||||||
|
};
|
||||||
|
|
||||||
export const useDashboardData = (): DashboardDataState => {
|
export const useDashboardData = (): DashboardDataState => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
|
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
|
||||||
@@ -184,64 +235,171 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
const [statisticsFlows, setStatisticsFlows] = useState<
|
const [statisticsFlows, setStatisticsFlows] = useState<
|
||||||
DashboardStatisticsFlow[]
|
DashboardStatisticsFlow[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [nodeExpiryReminders, setNodeExpiryReminders] = useState<
|
||||||
|
DashboardNodeExpiryItem[]
|
||||||
|
>([]);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const packageRequestInFlightRef = useRef(false);
|
||||||
|
const nodeExpiryRequestInFlightRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const applyPackageData = useCallback((data: {
|
||||||
const loadAnnouncement = async () => {
|
userInfo?: DashboardUserInfo;
|
||||||
try {
|
tunnelPermissions?: DashboardUserTunnel[];
|
||||||
const res = await getAnnouncement();
|
forwards?: ForwardApiItem[];
|
||||||
|
statisticsFlows?: DashboardStatisticsFlow[];
|
||||||
|
}) => {
|
||||||
|
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||||
|
data.tunnelPermissions || [],
|
||||||
|
);
|
||||||
|
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||||
|
|
||||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
if (!isMountedRef.current) {
|
||||||
setAnnouncement(res.data);
|
return;
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
};
|
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||||
|
setUserTunnels(normalizedTunnelPermissions);
|
||||||
|
setForwardList(normalizedForwards);
|
||||||
|
setStatisticsFlows(data.statisticsFlows || []);
|
||||||
|
|
||||||
|
checkExpirationNotifications(
|
||||||
|
data.userInfo || ({} as DashboardUserInfo),
|
||||||
|
normalizedTunnelPermissions,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPackageData = useCallback(
|
||||||
|
async ({ silent = false, notifyOnError = false } = {}) => {
|
||||||
|
if (packageRequestInFlightRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
packageRequestInFlightRef.current = true;
|
||||||
|
|
||||||
|
if (!silent && isMountedRef.current) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
const loadPackageData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await getUserPackageInfo();
|
const res = await getUserPackageInfo();
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
const data = res.data;
|
applyPackageData(res.data || {});
|
||||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
} else if (notifyOnError) {
|
||||||
data.tunnelPermissions || [],
|
|
||||||
);
|
|
||||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
|
||||||
|
|
||||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
|
||||||
setUserTunnels(normalizedTunnelPermissions);
|
|
||||||
setForwardList(normalizedForwards);
|
|
||||||
setStatisticsFlows(data.statisticsFlows || []);
|
|
||||||
|
|
||||||
checkExpirationNotifications(
|
|
||||||
data.userInfo,
|
|
||||||
normalizedTunnelPermissions,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.error(res.msg || "获取套餐信息失败");
|
toast.error(res.msg || "获取套餐信息失败");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("获取套餐信息失败");
|
if (notifyOnError) {
|
||||||
|
toast.error("获取套餐信息失败");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
packageRequestInFlightRef.current = false;
|
||||||
|
|
||||||
|
if (!silent && isMountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyPackageData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadAnnouncement = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getAnnouncement();
|
||||||
|
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||||
|
setAnnouncement(res.data);
|
||||||
|
} else {
|
||||||
|
setAnnouncement(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setAnnouncement(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadNodeExpiryData = useCallback(async () => {
|
||||||
|
if (nodeExpiryRequestInFlightRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeExpiryRequestInFlightRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getDashboardNodeExpiryList();
|
||||||
|
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.code === 0 && Array.isArray(res.data)) {
|
||||||
|
setNodeExpiryReminders(normalizeNodeExpiryReminders(res.data));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
nodeExpiryRequestInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
const adminFlag = getAdminFlag();
|
||||||
|
|
||||||
|
setIsAdmin(adminFlag);
|
||||||
|
|
||||||
|
void loadPackageData({ notifyOnError: true });
|
||||||
|
void loadAnnouncement();
|
||||||
|
if (adminFlag) {
|
||||||
|
void loadNodeExpiryData();
|
||||||
|
}
|
||||||
|
localStorage.setItem("e", "/dashboard");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [loadAnnouncement, loadNodeExpiryData, loadPackageData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
void loadPackageData({ silent: true });
|
||||||
|
if (isAdmin) {
|
||||||
|
void loadNodeExpiryData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setLoading(true);
|
const interval = window.setInterval(() => {
|
||||||
setUserInfo({} as DashboardUserInfo);
|
if (document.visibilityState !== "visible") {
|
||||||
setUserTunnels([]);
|
return;
|
||||||
setForwardList([]);
|
}
|
||||||
setStatisticsFlows([]);
|
|
||||||
setIsAdmin(getAdminFlag());
|
|
||||||
|
|
||||||
loadPackageData();
|
void loadPackageData({ silent: true });
|
||||||
loadAnnouncement();
|
if (isAdmin) {
|
||||||
localStorage.setItem("e", "/dashboard");
|
void loadNodeExpiryData();
|
||||||
}, []);
|
}
|
||||||
|
}, DASHBOARD_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [isAdmin, loadNodeExpiryData, loadPackageData]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
@@ -249,6 +407,7 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
userTunnels,
|
userTunnels,
|
||||||
forwardList,
|
forwardList,
|
||||||
statisticsFlows,
|
statisticsFlows,
|
||||||
|
nodeExpiryReminders,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
announcement,
|
announcement,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SpeedLimitApiItem } from "@/api/types";
|
import type { ForwardApiItem, SpeedLimitApiItem } from "@/api/types";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef } from "react";
|
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -48,6 +48,7 @@ import { Chip } from "@/shadcn-bridge/heroui/chip";
|
|||||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||||
|
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||||
import {
|
import {
|
||||||
createForward,
|
createForward,
|
||||||
@@ -103,6 +104,7 @@ interface Forward {
|
|||||||
name: string;
|
name: string;
|
||||||
tunnelId: number;
|
tunnelId: number;
|
||||||
tunnelName: string;
|
tunnelName: string;
|
||||||
|
tunnelTrafficRatio?: number;
|
||||||
inIp: string;
|
inIp: string;
|
||||||
inPort: number;
|
inPort: number;
|
||||||
remoteAddr: string;
|
remoteAddr: string;
|
||||||
@@ -163,9 +165,16 @@ interface ForwardUserGroup {
|
|||||||
interface ForwardTunnelGroup {
|
interface ForwardTunnelGroup {
|
||||||
tunnelKey: string;
|
tunnelKey: string;
|
||||||
tunnelName: string;
|
tunnelName: string;
|
||||||
|
tunnelTrafficRatio?: number;
|
||||||
items: Forward[];
|
items: Forward[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchProgressState {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
type ForwardGroupOrderMap = Record<string, string[]>;
|
type ForwardGroupOrderMap = Record<string, string[]>;
|
||||||
type ForwardGroupCollapsedMap = Record<string, boolean>;
|
type ForwardGroupCollapsedMap = Record<string, boolean>;
|
||||||
|
|
||||||
@@ -238,6 +247,32 @@ const compareForwardTunnelGroupKeyAsc = (a: string, b: string): number => {
|
|||||||
return compareForwardTunnelNameAsc(a, b);
|
return compareForwardTunnelNameAsc(a, b);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeTunnelTrafficRatio = (value: unknown): number => {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTunnelTrafficRatio = (value?: number): string => {
|
||||||
|
const ratio = normalizeTunnelTrafficRatio(value);
|
||||||
|
|
||||||
|
if (Number.isInteger(ratio)) {
|
||||||
|
return `${ratio}x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parseFloat(ratio.toFixed(2))}x`;
|
||||||
|
};
|
||||||
|
|
||||||
const buildForwardGroupOrderLocalKey = (tokenUserId: number): string => {
|
const buildForwardGroupOrderLocalKey = (tokenUserId: number): string => {
|
||||||
return `${FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`;
|
return `${FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`;
|
||||||
};
|
};
|
||||||
@@ -490,6 +525,43 @@ const isSameGroupCollapsedMap = (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeForwardItems = (items: Forward[]): Forward[] => {
|
||||||
|
return items.map((forward) => ({
|
||||||
|
...forward,
|
||||||
|
serviceRunning: forward.status === 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {
|
||||||
|
return (items || []).map((forward) => ({
|
||||||
|
id: forward.id,
|
||||||
|
name: forward.name,
|
||||||
|
tunnelId: forward.tunnelId ?? 0,
|
||||||
|
tunnelName: forward.tunnelName || "",
|
||||||
|
tunnelTrafficRatio: normalizeTunnelTrafficRatio(
|
||||||
|
forward.tunnelTrafficRatio,
|
||||||
|
),
|
||||||
|
inIp: forward.inIp || "",
|
||||||
|
inPort: forward.inPort ?? 0,
|
||||||
|
remoteAddr: forward.remoteAddr || "",
|
||||||
|
strategy: typeof forward.strategy === "string" ? forward.strategy : "fifo",
|
||||||
|
status: typeof forward.status === "number" ? forward.status : 0,
|
||||||
|
inFlow: forward.inFlow ?? 0,
|
||||||
|
outFlow: forward.outFlow ?? 0,
|
||||||
|
createdTime:
|
||||||
|
typeof forward.createdTime === "string" ? forward.createdTime : "",
|
||||||
|
userName:
|
||||||
|
typeof forward.userName === "string" ? forward.userName : undefined,
|
||||||
|
userId: typeof forward.userId === "number" ? forward.userId : undefined,
|
||||||
|
inx: typeof forward.inx === "number" ? forward.inx : undefined,
|
||||||
|
speedId:
|
||||||
|
typeof forward.speedId === "number" || forward.speedId === null
|
||||||
|
? forward.speedId
|
||||||
|
: undefined,
|
||||||
|
serviceRunning: forward.status === 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
export default function ForwardPage() {
|
export default function ForwardPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [forwards, setForwards] = useState<Forward[]>([]);
|
const [forwards, setForwards] = useState<Forward[]>([]);
|
||||||
@@ -603,6 +675,11 @@ export default function ForwardPage() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||||
|
active: false,
|
||||||
|
label: "",
|
||||||
|
percent: 0,
|
||||||
|
});
|
||||||
const [groupOrderMap, setGroupOrderMap] = useState<ForwardGroupOrderMap>({});
|
const [groupOrderMap, setGroupOrderMap] = useState<ForwardGroupOrderMap>({});
|
||||||
const [collapsedTunnelGroups, setCollapsedTunnelGroups] =
|
const [collapsedTunnelGroups, setCollapsedTunnelGroups] =
|
||||||
useState<ForwardGroupCollapsedMap>({});
|
useState<ForwardGroupCollapsedMap>({});
|
||||||
@@ -959,7 +1036,7 @@ export default function ForwardPage() {
|
|||||||
return Number.isFinite(shareId) && shareId > 0 ? shareId : null;
|
return Number.isFinite(shareId) && shareId > 0 ? shareId : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mergeFederationShareFlow = async (
|
const mergeFederationShareFlow = useCallback(async (
|
||||||
forwardsData: Forward[],
|
forwardsData: Forward[],
|
||||||
): Promise<Forward[]> => {
|
): Promise<Forward[]> => {
|
||||||
if (forwardsData.length === 0) {
|
if (forwardsData.length === 0) {
|
||||||
@@ -1135,7 +1212,7 @@ export default function ForwardPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
return forwardsData;
|
return forwardsData;
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const getForwardDisplayFlow = (forward: Forward): number => {
|
const getForwardDisplayFlow = (forward: Forward): number => {
|
||||||
const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);
|
const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);
|
||||||
@@ -1147,10 +1224,6 @@ export default function ForwardPage() {
|
|||||||
return forward.federationShareFlow || 0;
|
return forward.federationShareFlow || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 切换显示模式并保存到localStorage
|
// 切换显示模式并保存到localStorage
|
||||||
const handleViewModeChange = () => {
|
const handleViewModeChange = () => {
|
||||||
const newMode = viewMode === "grouped" ? "direct" : "grouped";
|
const newMode = viewMode === "grouped" ? "direct" : "grouped";
|
||||||
@@ -1161,12 +1234,59 @@ export default function ForwardPage() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyForwardList = useCallback(
|
||||||
|
async (items: Forward[]) => {
|
||||||
|
const mergedForwards = await mergeFederationShareFlow(
|
||||||
|
normalizeForwardItems(items),
|
||||||
|
);
|
||||||
|
|
||||||
|
setForwards(mergedForwards);
|
||||||
|
|
||||||
|
const currentUserId = JwtUtil.getUserIdFromToken();
|
||||||
|
const { order, fromDatabase } = buildForwardOrder(
|
||||||
|
mergedForwards,
|
||||||
|
currentUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
setForwardOrder(order);
|
||||||
|
|
||||||
|
if (fromDatabase) {
|
||||||
|
saveOrder(FORWARD_ORDER_KEY, order);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mergeFederationShareFlow],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshForwardList = useCallback(
|
||||||
|
async (lod = true) => {
|
||||||
|
if (lod) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const forwardsRes = await getForwardList();
|
||||||
|
|
||||||
|
if (forwardsRes.code === 0) {
|
||||||
|
await applyForwardList(mapForwardApiItems(forwardsRes.data || []));
|
||||||
|
} else {
|
||||||
|
toast.error(forwardsRes.msg || "获取规则列表失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("获取规则列表失败");
|
||||||
|
} finally {
|
||||||
|
if (lod) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyForwardList],
|
||||||
|
);
|
||||||
|
|
||||||
// 加载所有数据
|
// 加载所有数据
|
||||||
const loadData = async (lod = true) => {
|
const loadData = useCallback(async (lod = true) => {
|
||||||
setLoading(lod);
|
setLoading(lod);
|
||||||
try {
|
try {
|
||||||
const [forwardsRes, tunnelsRes, speedLimitsRes] = await Promise.all([
|
const [tunnelsRes, speedLimitsRes] = await Promise.all([
|
||||||
getForwardList(),
|
|
||||||
userTunnel(),
|
userTunnel(),
|
||||||
getSpeedLimitList(),
|
getSpeedLimitList(),
|
||||||
]);
|
]);
|
||||||
@@ -1175,32 +1295,7 @@ export default function ForwardPage() {
|
|||||||
getNodeList(),
|
getNodeList(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (forwardsRes.code === 0) {
|
await refreshForwardList(false);
|
||||||
const forwardsData =
|
|
||||||
forwardsRes.data?.map((forward: any) => ({
|
|
||||||
...forward,
|
|
||||||
serviceRunning: forward.status === 1,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const mergedForwards = await mergeFederationShareFlow(forwardsData);
|
|
||||||
|
|
||||||
setForwards(mergedForwards);
|
|
||||||
|
|
||||||
// 初始化拖拽排序顺序
|
|
||||||
const currentUserId = JwtUtil.getUserIdFromToken();
|
|
||||||
const { order, fromDatabase } = buildForwardOrder(
|
|
||||||
mergedForwards,
|
|
||||||
currentUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
setForwardOrder(order);
|
|
||||||
|
|
||||||
if (fromDatabase) {
|
|
||||||
saveOrder(FORWARD_ORDER_KEY, order);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error(forwardsRes.msg || "获取规则列表失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tunnelsRes.code === 0) {
|
if (tunnelsRes.code === 0) {
|
||||||
setTunnels(tunnelsRes.data || []);
|
setTunnels(tunnelsRes.data || []);
|
||||||
@@ -1226,7 +1321,11 @@ export default function ForwardPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [refreshForwardList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
// 表单验证
|
// 表单验证
|
||||||
const noLimitSpeedLimitIds = useMemo(() => {
|
const noLimitSpeedLimitIds = useMemo(() => {
|
||||||
@@ -1385,7 +1484,20 @@ export default function ForwardPage() {
|
|||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("删除成功");
|
toast.success("删除成功");
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
loadData();
|
setForwardToDelete(null);
|
||||||
|
setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));
|
||||||
|
setForwardOrder((prev) => {
|
||||||
|
const next = prev.filter((id) => id !== forwardToDelete.id);
|
||||||
|
|
||||||
|
saveOrder(FORWARD_ORDER_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
|
||||||
|
next.delete(forwardToDelete.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// 删除失败,询问是否强制删除
|
// 删除失败,询问是否强制删除
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
@@ -1398,7 +1510,22 @@ export default function ForwardPage() {
|
|||||||
if (forceRes.code === 0) {
|
if (forceRes.code === 0) {
|
||||||
toast.success("强制删除成功");
|
toast.success("强制删除成功");
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
loadData();
|
setForwardToDelete(null);
|
||||||
|
setForwards((prev) =>
|
||||||
|
prev.filter((forward) => forward.id !== forwardToDelete.id),
|
||||||
|
);
|
||||||
|
setForwardOrder((prev) => {
|
||||||
|
const next = prev.filter((id) => id !== forwardToDelete.id);
|
||||||
|
|
||||||
|
saveOrder(FORWARD_ORDER_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
|
||||||
|
next.delete(forwardToDelete.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(forceRes.msg || "强制删除失败");
|
toast.error(forceRes.msg || "强制删除失败");
|
||||||
}
|
}
|
||||||
@@ -1496,7 +1623,7 @@ export default function ForwardPage() {
|
|||||||
}
|
}
|
||||||
toast.success(isEdit ? "修改成功" : "创建成功");
|
toast.success(isEdit ? "修改成功" : "创建成功");
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
loadData();
|
await refreshForwardList(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || "操作失败");
|
toast.error(res.msg || "操作失败");
|
||||||
}
|
}
|
||||||
@@ -2091,7 +2218,7 @@ export default function ForwardPage() {
|
|||||||
|
|
||||||
toast.success("导入执行完成");
|
toast.success("导入执行完成");
|
||||||
|
|
||||||
await loadData(false);
|
await refreshForwardList(false);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("导入过程中发生错误");
|
toast.error("导入过程中发生错误");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2296,6 +2423,11 @@ export default function ForwardPage() {
|
|||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: `正在删除 ${selectedIds.size} 项规则...`,
|
||||||
|
percent: 30,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const outcome = await executeForwardBatchDelete(Array.from(selectedIds));
|
const outcome = await executeForwardBatchDelete(Array.from(selectedIds));
|
||||||
|
|
||||||
@@ -2306,14 +2438,20 @@ export default function ForwardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outcome.shouldRefresh) {
|
if (outcome.shouldRefresh) {
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: outcome.progressLabel || "正在刷新规则列表...",
|
||||||
|
percent: outcome.progressPercent ?? 75,
|
||||||
|
});
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
if (outcome.closeDeleteModal) {
|
if (outcome.closeDeleteModal) {
|
||||||
setBatchDeleteModalOpen(false);
|
setBatchDeleteModalOpen(false);
|
||||||
}
|
}
|
||||||
loadData(false);
|
await refreshForwardList(false);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||||
setBatchLoading(false);
|
setBatchLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2321,6 +2459,11 @@ export default function ForwardPage() {
|
|||||||
const handleBatchToggleService = async (enable: boolean) => {
|
const handleBatchToggleService = async (enable: boolean) => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: `正在${enable ? "启用" : "停用"} ${selectedIds.size} 项规则...`,
|
||||||
|
percent: 30,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const outcome = await executeForwardBatchToggleService(
|
const outcome = await executeForwardBatchToggleService(
|
||||||
Array.from(selectedIds),
|
Array.from(selectedIds),
|
||||||
@@ -2334,11 +2477,17 @@ export default function ForwardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outcome.shouldRefresh) {
|
if (outcome.shouldRefresh) {
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: outcome.progressLabel || "正在刷新规则列表...",
|
||||||
|
percent: outcome.progressPercent ?? 75,
|
||||||
|
});
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
loadData(false);
|
await refreshForwardList(false);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||||
setBatchLoading(false);
|
setBatchLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2346,6 +2495,11 @@ export default function ForwardPage() {
|
|||||||
const handleBatchRedeploy = async () => {
|
const handleBatchRedeploy = async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: `正在重新下发 ${selectedIds.size} 项规则...`,
|
||||||
|
percent: 30,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const outcome = await executeForwardBatchRedeploy(
|
const outcome = await executeForwardBatchRedeploy(
|
||||||
Array.from(selectedIds),
|
Array.from(selectedIds),
|
||||||
@@ -2358,11 +2512,17 @@ export default function ForwardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outcome.shouldRefresh) {
|
if (outcome.shouldRefresh) {
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: outcome.progressLabel || "正在刷新规则列表...",
|
||||||
|
percent: outcome.progressPercent ?? 75,
|
||||||
|
});
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
loadData(false);
|
await refreshForwardList(false);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||||
setBatchLoading(false);
|
setBatchLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2370,6 +2530,11 @@ export default function ForwardPage() {
|
|||||||
const handleBatchChangeTunnel = async () => {
|
const handleBatchChangeTunnel = async () => {
|
||||||
if (selectedIds.size === 0 || !batchTargetTunnelId) return;
|
if (selectedIds.size === 0 || !batchTargetTunnelId) return;
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: `正在为 ${selectedIds.size} 项规则切换隧道...`,
|
||||||
|
percent: 30,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const outcome = await executeForwardBatchChangeTunnel(
|
const outcome = await executeForwardBatchChangeTunnel(
|
||||||
Array.from(selectedIds),
|
Array.from(selectedIds),
|
||||||
@@ -2383,6 +2548,11 @@ export default function ForwardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outcome.shouldRefresh) {
|
if (outcome.shouldRefresh) {
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: outcome.progressLabel || "正在刷新规则列表...",
|
||||||
|
percent: outcome.progressPercent ?? 75,
|
||||||
|
});
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
if (outcome.closeChangeTunnelModal) {
|
if (outcome.closeChangeTunnelModal) {
|
||||||
@@ -2391,9 +2561,10 @@ export default function ForwardPage() {
|
|||||||
if (outcome.resetTargetTunnel) {
|
if (outcome.resetTargetTunnel) {
|
||||||
setBatchTargetTunnelId(null);
|
setBatchTargetTunnelId(null);
|
||||||
}
|
}
|
||||||
loadData(false);
|
await refreshForwardList(false);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||||
setBatchLoading(false);
|
setBatchLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2597,6 +2768,9 @@ export default function ForwardPage() {
|
|||||||
existingGroup.tunnelMap.set(tunnelKey, {
|
existingGroup.tunnelMap.set(tunnelKey, {
|
||||||
tunnelKey,
|
tunnelKey,
|
||||||
tunnelName,
|
tunnelName,
|
||||||
|
tunnelTrafficRatio: normalizeTunnelTrafficRatio(
|
||||||
|
forward.tunnelTrafficRatio,
|
||||||
|
),
|
||||||
items: [forward],
|
items: [forward],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2611,6 +2785,15 @@ export default function ForwardPage() {
|
|||||||
) {
|
) {
|
||||||
existingTunnelGroup.tunnelName = tunnelName;
|
existingTunnelGroup.tunnelName = tunnelName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 &&
|
||||||
|
normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1
|
||||||
|
) {
|
||||||
|
existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(
|
||||||
|
forward.tunnelTrafficRatio,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = Array.from(userGroupMap.values()).map((group) => {
|
const groups = Array.from(userGroupMap.values()).map((group) => {
|
||||||
@@ -2787,6 +2970,14 @@ export default function ForwardPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
<span className={titleClassName}>{tunnel.tunnelName}</span>
|
<span className={titleClassName}>{tunnel.tunnelName}</span>
|
||||||
|
<Chip
|
||||||
|
className="h-5 border-none bg-secondary/15 px-1.5 text-[11px] font-semibold text-secondary-700"
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)}
|
||||||
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={countClassName}>{tunnel.items.length} 条规则</span>
|
<span className={countClassName}>{tunnel.items.length} 条规则</span>
|
||||||
@@ -3182,6 +3373,9 @@ export default function ForwardPage() {
|
|||||||
<span className="font-medium text-secondary-700">
|
<span className="font-medium text-secondary-700">
|
||||||
{forward.tunnelName}
|
{forward.tunnelName}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="ml-1 text-secondary-600/80">
|
||||||
|
{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
|
||||||
|
</span>
|
||||||
</Chip>
|
</Chip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[220px]">
|
<TableCell className="max-w-[220px]">
|
||||||
@@ -3337,7 +3531,7 @@ export default function ForwardPage() {
|
|||||||
{forward.name}
|
{forward.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-default-500 truncate">
|
<p className="text-xs text-default-500 truncate">
|
||||||
{normalizeForwardTunnelName(forward.tunnelName)}
|
{`${normalizeForwardTunnelName(forward.tunnelName)} · ${formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
@@ -3817,6 +4011,23 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{batchProgress.active && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<Alert
|
||||||
|
color="primary"
|
||||||
|
description={batchProgress.label}
|
||||||
|
variant="flat"
|
||||||
|
/>
|
||||||
|
<Progress
|
||||||
|
aria-label={batchProgress.label}
|
||||||
|
className="mt-3"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
value={batchProgress.percent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 根据显示模式渲染不同内容 */}
|
{/* 根据显示模式渲染不同内容 */}
|
||||||
{compactMode ? (
|
{compactMode ? (
|
||||||
viewMode === "grouped" ? (
|
viewMode === "grouped" ? (
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface ForwardBatchActionOutcome {
|
|||||||
toastVariant: "success" | "error";
|
toastVariant: "success" | "error";
|
||||||
toastMessage: string;
|
toastMessage: string;
|
||||||
shouldRefresh: boolean;
|
shouldRefresh: boolean;
|
||||||
|
progressPercent?: number;
|
||||||
|
progressLabel?: string;
|
||||||
closeDeleteModal?: boolean;
|
closeDeleteModal?: boolean;
|
||||||
closeChangeTunnelModal?: boolean;
|
closeChangeTunnelModal?: boolean;
|
||||||
resetTargetTunnel?: boolean;
|
resetTargetTunnel?: boolean;
|
||||||
@@ -63,6 +65,8 @@ export const executeForwardBatchDelete = async (
|
|||||||
return {
|
return {
|
||||||
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
|
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
|
progressPercent: 100,
|
||||||
|
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
|
||||||
closeDeleteModal: true,
|
closeDeleteModal: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -103,6 +107,8 @@ export const executeForwardBatchToggleService = async (
|
|||||||
: `成功停用 ${summary.successCount} 项`,
|
: `成功停用 ${summary.successCount} 项`,
|
||||||
),
|
),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
|
progressPercent: 100,
|
||||||
|
progressLabel: `${enable ? "启用" : "停用"}完成:成功 ${summary.successCount} 项`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -132,6 +138,8 @@ export const executeForwardBatchRedeploy = async (
|
|||||||
return {
|
return {
|
||||||
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
|
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
|
progressPercent: 100,
|
||||||
|
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -165,6 +173,8 @@ export const executeForwardBatchChangeTunnel = async (
|
|||||||
return {
|
return {
|
||||||
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
|
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
|
progressPercent: 100,
|
||||||
|
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
|
||||||
closeChangeTunnelModal: true,
|
closeChangeTunnelModal: true,
|
||||||
resetTargetTunnel: true,
|
resetTargetTunnel: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,10 +65,15 @@ import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
|||||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||||
|
|
||||||
|
const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
id: number;
|
id: number;
|
||||||
inx?: number;
|
inx?: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
remark?: string;
|
||||||
|
tags?: string;
|
||||||
|
expiryTime?: number;
|
||||||
ip: string;
|
ip: string;
|
||||||
serverIp: string;
|
serverIp: string;
|
||||||
serverIpV4?: string;
|
serverIpV4?: string;
|
||||||
@@ -103,6 +108,9 @@ interface Node {
|
|||||||
interface NodeForm {
|
interface NodeForm {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
remark: string;
|
||||||
|
tags: string;
|
||||||
|
expiryTime: number;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
serverIpV4: string;
|
serverIpV4: string;
|
||||||
serverIpV6: string;
|
serverIpV6: string;
|
||||||
@@ -116,6 +124,95 @@ interface NodeForm {
|
|||||||
socks: number; // 0 关 1 开
|
socks: number; // 0 关 1 开
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatNodeExpiry = (timestamp?: number): string => {
|
||||||
|
if (!timestamp || timestamp <= 0) return "永久有效";
|
||||||
|
return new Date(timestamp).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPIRING_SOON_DAYS = 7;
|
||||||
|
|
||||||
|
type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired";
|
||||||
|
|
||||||
|
type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry";
|
||||||
|
|
||||||
|
const getNodeExpiryMeta = (timestamp?: number) => {
|
||||||
|
if (!timestamp || timestamp <= 0) {
|
||||||
|
return {
|
||||||
|
state: "permanent" as NodeExpiryState,
|
||||||
|
label: "永久有效",
|
||||||
|
tone: "default" as const,
|
||||||
|
accentClassName: "",
|
||||||
|
bannerClassName: "",
|
||||||
|
isHighlighted: false,
|
||||||
|
sortWeight: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = timestamp - Date.now();
|
||||||
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays <= 0) {
|
||||||
|
return {
|
||||||
|
state: "expired" as NodeExpiryState,
|
||||||
|
label: "已过期",
|
||||||
|
tone: "danger" as const,
|
||||||
|
accentClassName:
|
||||||
|
"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20",
|
||||||
|
bannerClassName:
|
||||||
|
"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300",
|
||||||
|
isHighlighted: true,
|
||||||
|
sortWeight: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffDays <= EXPIRING_SOON_DAYS) {
|
||||||
|
return {
|
||||||
|
state: "expiringSoon" as NodeExpiryState,
|
||||||
|
label: diffDays === 1 ? "明天到期" : `${diffDays}天后到期`,
|
||||||
|
tone: "warning" as const,
|
||||||
|
accentClassName:
|
||||||
|
"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20",
|
||||||
|
bannerClassName:
|
||||||
|
"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300",
|
||||||
|
isHighlighted: true,
|
||||||
|
sortWeight: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "healthy" as NodeExpiryState,
|
||||||
|
label: `${diffDays}天后到期`,
|
||||||
|
tone: "success" as const,
|
||||||
|
accentClassName: "",
|
||||||
|
bannerClassName: "",
|
||||||
|
isHighlighted: false,
|
||||||
|
sortWeight: 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNodeTags = (tags?: string): string[] => {
|
||||||
|
if (!tags) return [];
|
||||||
|
return tags
|
||||||
|
.split(",")
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeNodeRealtimeState = (
|
||||||
|
incomingNode: Node,
|
||||||
|
existingNode?: Node,
|
||||||
|
): Node => {
|
||||||
|
return {
|
||||||
|
...incomingNode,
|
||||||
|
systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,
|
||||||
|
copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,
|
||||||
|
upgradeLoading:
|
||||||
|
existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,
|
||||||
|
rollbackLoading:
|
||||||
|
existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const SortableItem = ({
|
const SortableItem = ({
|
||||||
id,
|
id,
|
||||||
children,
|
children,
|
||||||
@@ -165,6 +262,10 @@ export default function NodePage() {
|
|||||||
"node-search-keyword",
|
"node-search-keyword",
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
|
const [nodeFilterMode, setNodeFilterMode] = useLocalStorageState<NodeFilterMode>(
|
||||||
|
"node-expiry-filter-mode",
|
||||||
|
"all",
|
||||||
|
);
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
const [dialogVisible, setDialogVisible] = useState(false);
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
const [dialogTitle, setDialogTitle] = useState("");
|
const [dialogTitle, setDialogTitle] = useState("");
|
||||||
@@ -178,6 +279,9 @@ export default function NodePage() {
|
|||||||
const [form, setForm] = useState<NodeForm>({
|
const [form, setForm] = useState<NodeForm>({
|
||||||
id: null,
|
id: null,
|
||||||
name: "",
|
name: "",
|
||||||
|
remark: "",
|
||||||
|
tags: "",
|
||||||
|
expiryTime: 0,
|
||||||
serverHost: "",
|
serverHost: "",
|
||||||
serverIpV4: "",
|
serverIpV4: "",
|
||||||
serverIpV6: "",
|
serverIpV6: "",
|
||||||
@@ -251,8 +355,13 @@ export default function NodePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 加载节点列表
|
// 加载节点列表
|
||||||
const loadNodes = useCallback(async () => {
|
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
||||||
setLoading(true);
|
const silent = options?.silent ?? false;
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getNodeList();
|
const res = await getNodeList();
|
||||||
|
|
||||||
@@ -270,7 +379,13 @@ export default function NodePage() {
|
|||||||
copyLoading: false,
|
copyLoading: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setNodeList(nodesData);
|
setNodeList((prev) => {
|
||||||
|
const previousById = new Map(prev.map((node) => [node.id, node]));
|
||||||
|
|
||||||
|
return nodesData.map((node) =>
|
||||||
|
mergeNodeRealtimeState(node, previousById.get(node.id)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||||
const hasDbOrdering = nodesData.some(
|
const hasDbOrdering = nodesData.some(
|
||||||
@@ -292,12 +407,18 @@ export default function NodePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || "加载节点列表失败");
|
if (!silent) {
|
||||||
|
toast.error(res.msg || "加载节点列表失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("网络错误,请重试");
|
if (!silent) {
|
||||||
|
toast.error("网络错误,请重试");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!silent) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -370,7 +491,7 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { wsConnected, wsConnecting } = useNodeRealtime({
|
const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({
|
||||||
onMessage: handleWebSocketMessage,
|
onMessage: handleWebSocketMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -378,6 +499,22 @@ export default function NodePage() {
|
|||||||
loadNodes();
|
loadNodes();
|
||||||
}, [loadNodes]);
|
}, [loadNodes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!usingPollingFallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadNodes({ silent: true });
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void loadNodes({ silent: true });
|
||||||
|
}, NODE_FALLBACK_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [loadNodes, usingPollingFallback]);
|
||||||
|
|
||||||
// 格式化速度
|
// 格式化速度
|
||||||
const formatSpeed = (bytesPerSecond: number): string => {
|
const formatSpeed = (bytesPerSecond: number): string => {
|
||||||
if (bytesPerSecond === 0) return "0 B/s";
|
if (bytesPerSecond === 0) return "0 B/s";
|
||||||
@@ -586,6 +723,9 @@ export default function NodePage() {
|
|||||||
setForm({
|
setForm({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
|
remark: node.remark || "",
|
||||||
|
tags: node.tags || "",
|
||||||
|
expiryTime: node.expiryTime || 0,
|
||||||
serverHost: normalizedHost,
|
serverHost: normalizedHost,
|
||||||
serverIpV4: normalizedV4,
|
serverIpV4: normalizedV4,
|
||||||
serverIpV6: normalizedV6,
|
serverIpV6: normalizedV6,
|
||||||
@@ -821,6 +961,9 @@ export default function NodePage() {
|
|||||||
const { serverHost, ...rest } = form;
|
const { serverHost, ...rest } = form;
|
||||||
const data = {
|
const data = {
|
||||||
...rest,
|
...rest,
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
tags: form.tags.trim(),
|
||||||
|
expiryTime: form.expiryTime,
|
||||||
extraIPs: form.extraIPs,
|
extraIPs: form.extraIPs,
|
||||||
serverIp:
|
serverIp:
|
||||||
form.serverIpV4?.trim() ||
|
form.serverIpV4?.trim() ||
|
||||||
@@ -842,6 +985,9 @@ export default function NodePage() {
|
|||||||
? {
|
? {
|
||||||
...n,
|
...n,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
tags: form.tags.trim(),
|
||||||
|
expiryTime: form.expiryTime,
|
||||||
serverIp:
|
serverIp:
|
||||||
form.serverIpV4?.trim() ||
|
form.serverIpV4?.trim() ||
|
||||||
form.serverIpV6?.trim() ||
|
form.serverIpV6?.trim() ||
|
||||||
@@ -878,6 +1024,9 @@ export default function NodePage() {
|
|||||||
setForm({
|
setForm({
|
||||||
id: null,
|
id: null,
|
||||||
name: "",
|
name: "",
|
||||||
|
remark: "",
|
||||||
|
tags: "",
|
||||||
|
expiryTime: 0,
|
||||||
serverHost: "",
|
serverHost: "",
|
||||||
serverIpV4: "",
|
serverIpV4: "",
|
||||||
serverIpV6: "",
|
serverIpV6: "",
|
||||||
@@ -1010,6 +1159,20 @@ export default function NodePage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nodeExpiryStats = useMemo(() => {
|
||||||
|
return nodeList.reduce(
|
||||||
|
(acc, node) => {
|
||||||
|
const meta = getNodeExpiryMeta(node.expiryTime);
|
||||||
|
|
||||||
|
if (meta.state === "expired") acc.expired += 1;
|
||||||
|
if (meta.state === "expiringSoon") acc.expiringSoon += 1;
|
||||||
|
if (node.expiryTime && node.expiryTime > 0) acc.withExpiry += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
|
||||||
|
);
|
||||||
|
}, [nodeList]);
|
||||||
|
|
||||||
// 根据排序顺序获取节点列表
|
// 根据排序顺序获取节点列表
|
||||||
const sortedNodes = useMemo((): Node[] => {
|
const sortedNodes = useMemo((): Node[] => {
|
||||||
if (!nodeList || nodeList.length === 0) return [];
|
if (!nodeList || nodeList.length === 0) return [];
|
||||||
@@ -1022,13 +1185,40 @@ export default function NodePage() {
|
|||||||
filteredNodes = filteredNodes.filter(
|
filteredNodes = filteredNodes.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
||||||
|
(n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||
|
||||||
|
(n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||
|
||||||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
||||||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
||||||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
|
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nodeFilterMode !== "all") {
|
||||||
|
filteredNodes = filteredNodes.filter((node) => {
|
||||||
|
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
|
||||||
|
|
||||||
|
switch (nodeFilterMode) {
|
||||||
|
case "expiringSoon":
|
||||||
|
return expiryMeta.state === "expiringSoon";
|
||||||
|
case "expired":
|
||||||
|
return expiryMeta.state === "expired";
|
||||||
|
case "withExpiry":
|
||||||
|
return !!node.expiryTime && node.expiryTime > 0;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const sortedByDb = [...filteredNodes].sort((a, b) => {
|
const sortedByDb = [...filteredNodes].sort((a, b) => {
|
||||||
|
const expiryDiff =
|
||||||
|
getNodeExpiryMeta(a.expiryTime).sortWeight -
|
||||||
|
getNodeExpiryMeta(b.expiryTime).sortWeight;
|
||||||
|
|
||||||
|
if (expiryDiff !== 0) {
|
||||||
|
return expiryDiff;
|
||||||
|
}
|
||||||
|
|
||||||
const aInx = a.inx ?? 0;
|
const aInx = a.inx ?? 0;
|
||||||
const bInx = b.inx ?? 0;
|
const bInx = b.inx ?? 0;
|
||||||
|
|
||||||
@@ -1060,7 +1250,7 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortedByDb;
|
return sortedByDb;
|
||||||
}, [nodeList, nodeOrder, searchKeyword]);
|
}, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);
|
||||||
|
|
||||||
const sortableNodeIds = useMemo(
|
const sortableNodeIds = useMemo(
|
||||||
() => sortedNodes.map((n) => n.id),
|
() => sortedNodes.map((n) => n.id),
|
||||||
@@ -1070,15 +1260,41 @@ export default function NodePage() {
|
|||||||
return (
|
return (
|
||||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
isVisible={isSearchVisible}
|
isVisible={isSearchVisible}
|
||||||
placeholder="搜索节点名称或IP"
|
placeholder="搜索名称、IP、备注或标签"
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
onChange={setSearchKeyword}
|
onChange={setSearchKeyword}
|
||||||
onClose={() => setIsSearchVisible(false)}
|
onClose={() => setIsSearchVisible(false)}
|
||||||
onOpen={() => setIsSearchVisible(true)}
|
onOpen={() => setIsSearchVisible(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
className="w-full max-w-sm lg:w-56"
|
||||||
|
label="到期筛选"
|
||||||
|
selectedKeys={[nodeFilterMode]}
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected = Array.from(keys)[0] as NodeFilterMode | undefined;
|
||||||
|
|
||||||
|
setNodeFilterMode(selected || "all");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="all" textValue="全部节点">
|
||||||
|
全部节点
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="expiringSoon" textValue="7天内到期">
|
||||||
|
7天内到期 ({nodeExpiryStats.expiringSoon})
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="expired" textValue="已过期">
|
||||||
|
已过期 ({nodeExpiryStats.expired})
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="withExpiry" textValue="已设置到期时间">
|
||||||
|
已设置到期时间 ({nodeExpiryStats.withExpiry})
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
||||||
@@ -1162,7 +1378,11 @@ export default function NodePage() {
|
|||||||
className="mb-4"
|
className="mb-4"
|
||||||
color="warning"
|
color="warning"
|
||||||
description={
|
description={
|
||||||
wsConnecting ? "监控连接中..." : "监控连接已断开,正在重连..."
|
wsConnecting
|
||||||
|
? "监控连接中..."
|
||||||
|
: usingPollingFallback
|
||||||
|
? "监控连接已断开,已切换为列表自动刷新兜底模式。"
|
||||||
|
: "监控连接已断开,正在重连..."
|
||||||
}
|
}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
/>
|
/>
|
||||||
@@ -1185,13 +1405,14 @@ export default function NodePage() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
{sortedNodes.map((node) => {
|
{sortedNodes.map((node) => {
|
||||||
const isRemoteNode = node.isRemote === 1;
|
const isRemoteNode = node.isRemote === 1;
|
||||||
|
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableItem key={node.id} id={node.id}>
|
<SortableItem key={node.id} id={node.id}>
|
||||||
{(listeners) => (
|
{(listeners) => (
|
||||||
<Card
|
<Card
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden ${expiryMeta.accentClassName}`}
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-2 md:pb-2">
|
<CardHeader className="pb-2 md:pb-2">
|
||||||
<div className="flex justify-between items-start w-full">
|
<div className="flex justify-between items-start w-full">
|
||||||
@@ -1259,8 +1480,60 @@ export default function NodePage() {
|
|||||||
{getRemoteSyncErrorMessage(node.syncError)}
|
{getRemoteSyncErrorMessage(node.syncError)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{expiryMeta.isHighlighted && (
|
||||||
|
<div
|
||||||
|
className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}
|
||||||
|
>
|
||||||
|
<span>节点到期提醒</span>
|
||||||
|
<span>{expiryMeta.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 基础信息 */}
|
{/* 基础信息 */}
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
|
{(node.remark?.trim() || node.tags?.trim()) && (
|
||||||
|
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2">
|
||||||
|
{node.remark?.trim() && (
|
||||||
|
<div className="text-xs leading-5 text-default-700 whitespace-pre-wrap break-words">
|
||||||
|
{node.remark.trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{normalizeNodeTags(node.tags).length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{normalizeNodeTags(node.tags).map((tag) => (
|
||||||
|
<Chip
|
||||||
|
key={`${node.id}-${tag}`}
|
||||||
|
className="text-[11px]"
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.expiryTime && node.expiryTime > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-default-600">到期时间</span>
|
||||||
|
<div className="text-right ml-2">
|
||||||
|
<div className="text-xs text-warning-700 dark:text-warning-400">
|
||||||
|
{formatNodeExpiry(node.expiryTime)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Chip
|
||||||
|
className="text-[11px]"
|
||||||
|
color={expiryMeta.tone}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{expiryMeta.label}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between items-center text-sm min-w-0">
|
<div className="flex justify-between items-center text-sm min-w-0">
|
||||||
<span className="text-default-600 flex-shrink-0">
|
<span className="text-default-600 flex-shrink-0">
|
||||||
IP
|
IP
|
||||||
@@ -1562,6 +1835,52 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
description="可记录供应商、用途、续费说明等补充信息"
|
||||||
|
label="备注"
|
||||||
|
maxRows={4}
|
||||||
|
minRows={3}
|
||||||
|
placeholder="例如: 搬瓦工年付,2026-12 续费,日本中转"
|
||||||
|
value={form.remark}
|
||||||
|
variant="bordered"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, remark: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
description="多个标签用逗号分隔,可用于搜索过滤"
|
||||||
|
label="标签"
|
||||||
|
placeholder="例如: 年付,日本中转,测试机"
|
||||||
|
value={form.tags}
|
||||||
|
variant="bordered"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, tags: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
description="留空表示永久有效,可用于记录节点到期时间"
|
||||||
|
label="到期时间"
|
||||||
|
type="datetime-local"
|
||||||
|
value={
|
||||||
|
form.expiryTime > 0
|
||||||
|
? new Date(form.expiryTime).toISOString().slice(0, 16)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
variant="bordered"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
expiryTime: e.target.value
|
||||||
|
? new Date(e.target.value).getTime()
|
||||||
|
: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||||
errorMessage={errors.serverHost}
|
errorMessage={errors.serverHost}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ interface UseNodeRealtimeOptions {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_STANDARD_RECONNECT_ATTEMPTS = 5;
|
||||||
|
const STANDARD_RECONNECT_DELAY_MS = 3000;
|
||||||
|
const MAX_STANDARD_RECONNECT_DELAY_MS = 15000;
|
||||||
|
const FALLBACK_RECONNECT_DELAY_MS = 30000;
|
||||||
|
|
||||||
const getRealtimeWsUrl = (): string => {
|
const getRealtimeWsUrl = (): string => {
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
axios.defaults.baseURL ||
|
axios.defaults.baseURL ||
|
||||||
@@ -34,14 +39,13 @@ export const useNodeRealtime = ({
|
|||||||
}: UseNodeRealtimeOptions) => {
|
}: UseNodeRealtimeOptions) => {
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
const [wsConnecting, setWsConnecting] = useState(false);
|
const [wsConnecting, setWsConnecting] = useState(false);
|
||||||
|
const [usingPollingFallback, setUsingPollingFallback] = useState(false);
|
||||||
|
|
||||||
const websocketRef = useRef<WebSocket | null>(null);
|
const websocketRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
|
|
||||||
const maxReconnectAttempts = 5;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onMessageRef.current = onMessage;
|
onMessageRef.current = onMessage;
|
||||||
}, [onMessage]);
|
}, [onMessage]);
|
||||||
@@ -58,6 +62,7 @@ export const useNodeRealtime = ({
|
|||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
setWsConnected(false);
|
setWsConnected(false);
|
||||||
setWsConnecting(false);
|
setWsConnecting(false);
|
||||||
|
setUsingPollingFallback(false);
|
||||||
|
|
||||||
if (!websocketRef.current) {
|
if (!websocketRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -103,6 +108,7 @@ export const useNodeRealtime = ({
|
|||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
setWsConnected(true);
|
setWsConnected(true);
|
||||||
setWsConnecting(false);
|
setWsConnecting(false);
|
||||||
|
setUsingPollingFallback(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
websocketRef.current.onmessage = (event) => {
|
websocketRef.current.onmessage = (event) => {
|
||||||
@@ -122,15 +128,27 @@ export const useNodeRealtime = ({
|
|||||||
setWsConnected(false);
|
setWsConnected(false);
|
||||||
setWsConnecting(false);
|
setWsConnecting(false);
|
||||||
|
|
||||||
if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
if (!enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnectAttemptsRef.current += 1;
|
reconnectAttemptsRef.current += 1;
|
||||||
|
const exhaustedStandardRetries =
|
||||||
|
reconnectAttemptsRef.current >= MAX_STANDARD_RECONNECT_ATTEMPTS;
|
||||||
|
|
||||||
|
setUsingPollingFallback(exhaustedStandardRetries);
|
||||||
|
|
||||||
|
const reconnectDelay = exhaustedStandardRetries
|
||||||
|
? FALLBACK_RECONNECT_DELAY_MS
|
||||||
|
: Math.min(
|
||||||
|
STANDARD_RECONNECT_DELAY_MS * reconnectAttemptsRef.current,
|
||||||
|
MAX_STANDARD_RECONNECT_DELAY_MS,
|
||||||
|
);
|
||||||
|
|
||||||
reconnectTimerRef.current = setTimeout(() => {
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
reconnectTimerRef.current = null;
|
reconnectTimerRef.current = null;
|
||||||
connect();
|
connect();
|
||||||
}, 3000 * reconnectAttemptsRef.current);
|
}, reconnectDelay);
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
setWsConnected(false);
|
setWsConnected(false);
|
||||||
@@ -153,6 +171,7 @@ export const useNodeRealtime = ({
|
|||||||
return {
|
return {
|
||||||
wsConnected,
|
wsConnected,
|
||||||
wsConnecting,
|
wsConnecting,
|
||||||
|
usingPollingFallback,
|
||||||
reconnectRealtime: connect,
|
reconnectRealtime: connect,
|
||||||
disconnectRealtime: disconnect,
|
disconnectRealtime: disconnect,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from "react";
|
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -36,6 +36,7 @@ import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
|||||||
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
||||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||||
|
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||||
import {
|
import {
|
||||||
createTunnel,
|
createTunnel,
|
||||||
getTunnelList,
|
getTunnelList,
|
||||||
@@ -115,6 +116,29 @@ interface TunnelForm {
|
|||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchProgressState {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||||
|
|
||||||
|
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||||
|
return (items || []).map((tunnel) => ({
|
||||||
|
...tunnel,
|
||||||
|
inx: tunnel.inx ?? 0,
|
||||||
|
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
|
||||||
|
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
|
||||||
|
chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],
|
||||||
|
inIp: tunnel.inIp || "",
|
||||||
|
flow: tunnel.flow ?? 1,
|
||||||
|
trafficRatio: tunnel.trafficRatio ?? 1,
|
||||||
|
status: typeof tunnel.status === "number" ? tunnel.status : 0,
|
||||||
|
createdTime: tunnel.createdTime || "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
export default function TunnelPage() {
|
export default function TunnelPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
@@ -203,6 +227,11 @@ export default function TunnelPage() {
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||||
|
active: false,
|
||||||
|
label: "",
|
||||||
|
percent: 0,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -211,60 +240,78 @@ export default function TunnelPage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const applyTunnelList = useCallback((items: Tunnel[]) => {
|
||||||
loadData();
|
setTunnels(items);
|
||||||
|
|
||||||
|
const hasDbOrdering = items.some(
|
||||||
|
(tunnel) => tunnel.inx !== undefined && tunnel.inx !== 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDbOrdering) {
|
||||||
|
const dbOrder = [...items]
|
||||||
|
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||||
|
.map((tunnel) => tunnel.id);
|
||||||
|
|
||||||
|
setTunnelOrder(dbOrder);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTunnelOrder(
|
||||||
|
loadStoredOrder(
|
||||||
|
TUNNEL_ORDER_KEY,
|
||||||
|
items.map((tunnel) => tunnel.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 加载所有数据
|
const refreshTunnelList = useCallback(async (withLoading = true) => {
|
||||||
const loadData = async () => {
|
if (withLoading) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [tunnelsRes, nodesRes] = await Promise.all([
|
const tunnelsRes = await getTunnelList();
|
||||||
getTunnelList(),
|
|
||||||
getNodeList(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (tunnelsRes.code === 0) {
|
if (tunnelsRes.code === 0) {
|
||||||
const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({
|
applyTunnelList(mapTunnelApiItems(tunnelsRes.data || []));
|
||||||
...t,
|
|
||||||
inx: t.inx ?? 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTunnels(tunnelsData);
|
|
||||||
|
|
||||||
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
|
||||||
const hasDbOrdering = tunnelsData.some(
|
|
||||||
(t) => t.inx !== undefined && t.inx !== 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasDbOrdering) {
|
|
||||||
const dbOrder = [...tunnelsData]
|
|
||||||
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
|
||||||
.map((t) => t.id);
|
|
||||||
|
|
||||||
setTunnelOrder(dbOrder);
|
|
||||||
} else {
|
|
||||||
setTunnelOrder(
|
|
||||||
loadStoredOrder(
|
|
||||||
"tunnel-order",
|
|
||||||
tunnelsData.map((t) => t.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(tunnelsRes.msg || "获取隧道列表失败");
|
toast.error(tunnelsRes.msg || "获取隧道列表失败");
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("获取隧道列表失败");
|
||||||
|
} finally {
|
||||||
|
if (withLoading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [applyTunnelList]);
|
||||||
|
|
||||||
|
const refreshNodes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const nodesRes = await getNodeList();
|
||||||
|
|
||||||
if (nodesRes.code === 0) {
|
if (nodesRes.code === 0) {
|
||||||
setNodes(nodesRes.data || []);
|
setNodes(nodesRes.data || []);
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 加载所有数据
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([refreshTunnelList(false), refreshNodes()]);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("加载数据失败");
|
toast.error("加载数据失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [refreshNodes, refreshTunnelList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
// 表单验证
|
// 表单验证
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
@@ -327,7 +374,21 @@ export default function TunnelPage() {
|
|||||||
toast.success("删除成功");
|
toast.success("删除成功");
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setTunnelToDelete(null);
|
setTunnelToDelete(null);
|
||||||
loadData();
|
setTunnels((prev) =>
|
||||||
|
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
|
||||||
|
);
|
||||||
|
setTunnelOrder((prev) => {
|
||||||
|
const next = prev.filter((id) => id !== tunnelToDelete.id);
|
||||||
|
|
||||||
|
saveOrder(TUNNEL_ORDER_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
|
||||||
|
next.delete(tunnelToDelete.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "删除失败");
|
toast.error(response.msg || "删除失败");
|
||||||
}
|
}
|
||||||
@@ -500,7 +561,7 @@ export default function TunnelPage() {
|
|||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
toast.success(isEdit ? "更新成功" : "创建成功");
|
toast.success(isEdit ? "更新成功" : "创建成功");
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
loadData();
|
await refreshTunnelList(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
|
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
|
||||||
}
|
}
|
||||||
@@ -725,7 +786,7 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
setTunnelOrder(newOrder);
|
setTunnelOrder(newOrder);
|
||||||
|
|
||||||
saveOrder("tunnel-order", newOrder);
|
saveOrder(TUNNEL_ORDER_KEY, newOrder);
|
||||||
|
|
||||||
// 持久化到数据库
|
// 持久化到数据库
|
||||||
try {
|
try {
|
||||||
@@ -779,6 +840,11 @@ export default function TunnelPage() {
|
|||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: `正在删除 ${selectedIds.size} 条隧道...`,
|
||||||
|
percent: 30,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const res = await batchDeleteTunnels(Array.from(selectedIds));
|
const res = await batchDeleteTunnels(Array.from(selectedIds));
|
||||||
|
|
||||||
@@ -787,21 +853,39 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
if (result.failCount === 0) {
|
if (result.failCount === 0) {
|
||||||
toast.success(`成功删除 ${result.successCount} 项`);
|
toast.success(`成功删除 ${result.successCount} 项`);
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: `删除完成:成功 ${result.successCount} 项`,
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
setTunnels((prev) => prev.filter((tunnel) => !selectedIds.has(tunnel.id)));
|
||||||
|
setTunnelOrder((prev) => {
|
||||||
|
const next = prev.filter((id) => !selectedIds.has(id));
|
||||||
|
|
||||||
|
saveOrder(TUNNEL_ORDER_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||||
);
|
);
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||||
|
percent: 75,
|
||||||
|
});
|
||||||
|
await refreshTunnelList(false);
|
||||||
}
|
}
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
setBatchDeleteModalOpen(false);
|
setBatchDeleteModalOpen(false);
|
||||||
loadData();
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || "删除失败");
|
toast.error(res.msg || "删除失败");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(extractApiErrorMessage(error, "删除失败"));
|
toast.error(extractApiErrorMessage(error, "删除失败"));
|
||||||
} finally {
|
} finally {
|
||||||
|
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||||
setBatchLoading(false);
|
setBatchLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -809,6 +893,11 @@ export default function TunnelPage() {
|
|||||||
const handleBatchRedeploy = async () => {
|
const handleBatchRedeploy = async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: `正在重新下发 ${selectedIds.size} 条隧道...`,
|
||||||
|
percent: 30,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const res = await batchRedeployTunnels(Array.from(selectedIds));
|
const res = await batchRedeployTunnels(Array.from(selectedIds));
|
||||||
|
|
||||||
@@ -824,13 +913,19 @@ export default function TunnelPage() {
|
|||||||
}
|
}
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
loadData();
|
setBatchProgress({
|
||||||
|
active: true,
|
||||||
|
label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
await refreshTunnelList(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || "下发失败");
|
toast.error(res.msg || "下发失败");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(extractApiErrorMessage(error, "下发失败"));
|
toast.error(extractApiErrorMessage(error, "下发失败"));
|
||||||
} finally {
|
} finally {
|
||||||
|
setBatchProgress({ active: false, label: "", percent: 0 });
|
||||||
setBatchLoading(false);
|
setBatchLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1038,6 +1133,23 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{batchProgress.active && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<Alert
|
||||||
|
color="primary"
|
||||||
|
description={batchProgress.label}
|
||||||
|
variant="flat"
|
||||||
|
/>
|
||||||
|
<Progress
|
||||||
|
aria-label={batchProgress.label}
|
||||||
|
className="mt-3"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
value={batchProgress.percent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 隧道卡片网格 */}
|
{/* 隧道卡片网格 */}
|
||||||
{tunnels.length > 0 ? (
|
{tunnels.length > 0 ? (
|
||||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { parseDate } from "@internationalized/date";
|
import { parseDate } from "@internationalized/date";
|
||||||
|
|
||||||
@@ -66,6 +66,10 @@ import {
|
|||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import { PageLoadingState } from "@/components/page-state";
|
import { PageLoadingState } from "@/components/page-state";
|
||||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||||
|
import {
|
||||||
|
removeItemsById,
|
||||||
|
replaceItemById,
|
||||||
|
} from "@/utils/list-state";
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||||
@@ -122,6 +126,43 @@ const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
|
|||||||
return inFlow + outFlow;
|
return inFlow + outFlow;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const USER_SEARCH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
const normalizeUserItem = (item: Partial<User>): User => {
|
||||||
|
return {
|
||||||
|
id: Number(item.id ?? 0),
|
||||||
|
name: item.name,
|
||||||
|
user: String(item.user ?? ""),
|
||||||
|
status: Number(item.status ?? 0),
|
||||||
|
flow: Number(item.flow ?? 0),
|
||||||
|
num: Number(item.num ?? 0),
|
||||||
|
expTime: item.expTime,
|
||||||
|
flowResetTime: item.flowResetTime ?? 0,
|
||||||
|
createdTime: item.createdTime,
|
||||||
|
inFlow: Number(item.inFlow ?? 0),
|
||||||
|
outFlow: Number(item.outFlow ?? 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
|
||||||
|
return {
|
||||||
|
id: Number(item.id ?? 0),
|
||||||
|
userId: Number(item.userId ?? 0),
|
||||||
|
tunnelId: Number(item.tunnelId ?? 0),
|
||||||
|
tunnelName: String(item.tunnelName ?? ""),
|
||||||
|
status: Number(item.status ?? 0),
|
||||||
|
flow: Number(item.flow ?? 0),
|
||||||
|
num: Number(item.num ?? 0),
|
||||||
|
expTime: Number(item.expTime ?? 0),
|
||||||
|
flowResetTime: Number(item.flowResetTime ?? 0),
|
||||||
|
speedId: item.speedId ?? null,
|
||||||
|
speedLimitName: item.speedLimitName,
|
||||||
|
inFlow: Number(item.inFlow ?? 0),
|
||||||
|
outFlow: Number(item.outFlow ?? 0),
|
||||||
|
tunnelFlow: item.tunnelFlow,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function UserPage() {
|
export default function UserPage() {
|
||||||
// 状态管理
|
// 状态管理
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
@@ -136,6 +177,7 @@ export default function UserPage() {
|
|||||||
size: 10,
|
size: 10,
|
||||||
total: 0,
|
total: 0,
|
||||||
});
|
});
|
||||||
|
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// 用户表单相关状态
|
// 用户表单相关状态
|
||||||
const {
|
const {
|
||||||
@@ -259,26 +301,25 @@ export default function UserPage() {
|
|||||||
return !speedLimitIds.has(speedId);
|
return !speedLimitIds.has(speedId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
useEffect(() => {
|
|
||||||
loadUsers();
|
|
||||||
loadTunnels();
|
|
||||||
loadSpeedLimits();
|
|
||||||
loadUserGroups();
|
|
||||||
}, [pagination.current, pagination.size, searchKeyword]);
|
|
||||||
|
|
||||||
// 数据加载函数
|
// 数据加载函数
|
||||||
const loadUsers = async () => {
|
const loadUsers = useCallback(
|
||||||
|
async (keywordOverride?: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const keyword = keywordOverride ?? searchKeyword;
|
||||||
const response = await getAllUsers({
|
const response = await getAllUsers({
|
||||||
current: pagination.current,
|
current: pagination.current,
|
||||||
size: pagination.size,
|
size: pagination.size,
|
||||||
keyword: searchKeyword,
|
keyword,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
setUsers(Array.isArray(response.data) ? response.data : []);
|
const nextUsers = Array.isArray(response.data)
|
||||||
|
? response.data.map((item) => normalizeUserItem(item))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
setUsers(nextUsers);
|
||||||
|
setPagination((prev) => ({ ...prev, total: nextUsers.length }));
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "获取用户列表失败");
|
toast.error(response.msg || "获取用户列表失败");
|
||||||
}
|
}
|
||||||
@@ -287,9 +328,11 @@ export default function UserPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[pagination.current, pagination.size],
|
||||||
|
);
|
||||||
|
|
||||||
const loadTunnels = async () => {
|
const loadTunnels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await getTunnelList();
|
const response = await getTunnelList();
|
||||||
|
|
||||||
@@ -297,9 +340,9 @@ export default function UserPage() {
|
|||||||
setTunnels(Array.isArray(response.data) ? response.data : []);
|
setTunnels(Array.isArray(response.data) ? response.data : []);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const loadSpeedLimits = async () => {
|
const loadSpeedLimits = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await getSpeedLimitList();
|
const response = await getSpeedLimitList();
|
||||||
|
|
||||||
@@ -315,9 +358,9 @@ export default function UserPage() {
|
|||||||
setSpeedLimits(speedLimitList);
|
setSpeedLimits(speedLimitList);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const loadUserGroups = async () => {
|
const loadUserGroups = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await getUserGroupList();
|
const response = await getUserGroupList();
|
||||||
|
|
||||||
@@ -325,15 +368,19 @@ export default function UserPage() {
|
|||||||
setUserGroups(Array.isArray(response.data) ? response.data : []);
|
setUserGroups(Array.isArray(response.data) ? response.data : []);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const loadUserTunnels = async (userId: number) => {
|
const loadUserTunnels = useCallback(async (userId: number) => {
|
||||||
setTunnelListLoading(true);
|
setTunnelListLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await getUserTunnelList({ userId });
|
const response = await getUserTunnelList({ userId });
|
||||||
|
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
setUserTunnels(Array.isArray(response.data) ? response.data : []);
|
setUserTunnels(
|
||||||
|
Array.isArray(response.data)
|
||||||
|
? response.data.map((item) => normalizeUserTunnelItem(item))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "获取隧道权限列表失败");
|
toast.error(response.msg || "获取隧道权限列表失败");
|
||||||
}
|
}
|
||||||
@@ -342,12 +389,52 @@ export default function UserPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setTunnelListLoading(false);
|
setTunnelListLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTunnels();
|
||||||
|
void loadSpeedLimits();
|
||||||
|
void loadUserGroups();
|
||||||
|
}, [loadSpeedLimits, loadTunnels, loadUserGroups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadUsers();
|
||||||
|
}, [loadUsers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchDebounceRef.current) {
|
||||||
|
clearTimeout(searchDebounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchDebounceRef.current = setTimeout(() => {
|
||||||
|
setPagination((prev) => {
|
||||||
|
if (prev.current === 1) {
|
||||||
|
void loadUsers(searchKeyword);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, current: 1 };
|
||||||
|
});
|
||||||
|
}, USER_SEARCH_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (searchDebounceRef.current) {
|
||||||
|
clearTimeout(searchDebounceRef.current);
|
||||||
|
searchDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [loadUsers, searchKeyword]);
|
||||||
|
|
||||||
// 用户管理操作
|
// 用户管理操作
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
|
if (searchDebounceRef.current) {
|
||||||
|
clearTimeout(searchDebounceRef.current);
|
||||||
|
searchDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
loadUsers();
|
void loadUsers(searchKeyword);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
@@ -405,9 +492,17 @@ export default function UserPage() {
|
|||||||
|
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
toast.success("删除成功");
|
toast.success("删除成功");
|
||||||
loadUsers();
|
|
||||||
onDeleteModalClose();
|
onDeleteModalClose();
|
||||||
|
setUsers((prev) => removeItemsById(prev, [userToDelete.id]));
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
total: Math.max(prev.total - 1, 0),
|
||||||
|
}));
|
||||||
setUserToDelete(null);
|
setUserToDelete(null);
|
||||||
|
if (currentUser?.id === userToDelete.id) {
|
||||||
|
setCurrentUser(null);
|
||||||
|
setUserTunnels([]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "删除失败");
|
toast.error(response.msg || "删除失败");
|
||||||
}
|
}
|
||||||
@@ -442,7 +537,26 @@ export default function UserPage() {
|
|||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
toast.success(isEdit ? "更新成功" : "创建成功");
|
toast.success(isEdit ? "更新成功" : "创建成功");
|
||||||
onUserModalClose();
|
onUserModalClose();
|
||||||
loadUsers();
|
const responseUser = normalizeUserItem((response as any).data || {});
|
||||||
|
|
||||||
|
if (
|
||||||
|
isEdit &&
|
||||||
|
responseUser.id > 0 &&
|
||||||
|
pagination.current === 1 &&
|
||||||
|
!searchKeyword.trim()
|
||||||
|
) {
|
||||||
|
setUsers((prev) => replaceItemById(prev, responseUser));
|
||||||
|
} else if (
|
||||||
|
!isEdit &&
|
||||||
|
responseUser.id > 0 &&
|
||||||
|
pagination.current === 1 &&
|
||||||
|
!searchKeyword.trim()
|
||||||
|
) {
|
||||||
|
setUsers((prev) => [responseUser, ...prev].slice(0, pagination.size));
|
||||||
|
setPagination((prev) => ({ ...prev, total: prev.total + 1 }));
|
||||||
|
} else {
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
|
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
|
||||||
}
|
}
|
||||||
@@ -500,7 +614,7 @@ export default function UserPage() {
|
|||||||
}
|
}
|
||||||
toast.success(response.msg || "分配成功");
|
toast.success(response.msg || "分配成功");
|
||||||
setBatchTunnelSelections(new Map());
|
setBatchTunnelSelections(new Map());
|
||||||
loadUserTunnels(currentUser.id);
|
await loadUserTunnels(currentUser.id);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "分配失败");
|
toast.error(response.msg || "分配失败");
|
||||||
}
|
}
|
||||||
@@ -546,7 +660,19 @@ export default function UserPage() {
|
|||||||
toast.success("更新成功");
|
toast.success("更新成功");
|
||||||
onEditTunnelModalClose();
|
onEditTunnelModalClose();
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
loadUserTunnels(currentUser.id);
|
const nextTunnel = normalizeUserTunnelItem({
|
||||||
|
...editTunnelForm,
|
||||||
|
speedId: normalizeSpeedId(editTunnelForm.speedId),
|
||||||
|
speedLimitName:
|
||||||
|
normalizeSpeedId(editTunnelForm.speedId) !== null
|
||||||
|
? speedLimits.find(
|
||||||
|
(speedLimit) =>
|
||||||
|
speedLimit.id === normalizeSpeedId(editTunnelForm.speedId),
|
||||||
|
)?.name
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setUserTunnels((prev) => replaceItemById(prev, nextTunnel));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "更新失败");
|
toast.error(response.msg || "更新失败");
|
||||||
@@ -572,7 +698,7 @@ export default function UserPage() {
|
|||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
toast.success("删除成功");
|
toast.success("删除成功");
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
loadUserTunnels(currentUser.id);
|
setUserTunnels((prev) => removeItemsById(prev, [tunnelToDelete.id]));
|
||||||
}
|
}
|
||||||
onDeleteTunnelModalClose();
|
onDeleteTunnelModalClose();
|
||||||
setTunnelToDelete(null);
|
setTunnelToDelete(null);
|
||||||
@@ -603,8 +729,14 @@ export default function UserPage() {
|
|||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
toast.success("流量重置成功");
|
toast.success("流量重置成功");
|
||||||
onResetFlowModalClose();
|
onResetFlowModalClose();
|
||||||
|
const targetUserId = userToReset.id;
|
||||||
|
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((user) =>
|
||||||
|
user.id === targetUserId ? { ...user, inFlow: 0, outFlow: 0 } : user,
|
||||||
|
),
|
||||||
|
);
|
||||||
setUserToReset(null);
|
setUserToReset(null);
|
||||||
loadUsers(); // 重新加载用户列表
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "重置失败");
|
toast.error(response.msg || "重置失败");
|
||||||
}
|
}
|
||||||
@@ -634,10 +766,16 @@ export default function UserPage() {
|
|||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
toast.success("隧道流量重置成功");
|
toast.success("隧道流量重置成功");
|
||||||
onResetTunnelFlowModalClose();
|
onResetTunnelFlowModalClose();
|
||||||
|
const targetTunnelId = tunnelToReset.id;
|
||||||
|
|
||||||
|
setUserTunnels((prev) =>
|
||||||
|
prev.map((userTunnel) =>
|
||||||
|
userTunnel.id === targetTunnelId
|
||||||
|
? { ...userTunnel, inFlow: 0, outFlow: 0 }
|
||||||
|
: userTunnel,
|
||||||
|
),
|
||||||
|
);
|
||||||
setTunnelToReset(null);
|
setTunnelToReset(null);
|
||||||
if (currentUser) {
|
|
||||||
loadUserTunnels(currentUser.id); // 重新加载隧道权限列表
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.msg || "重置失败");
|
toast.error(response.msg || "重置失败");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export const removeItemsById = <T extends { id: number }>(
|
||||||
|
items: T[],
|
||||||
|
ids: Iterable<number>,
|
||||||
|
): T[] => {
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
|
||||||
|
return items.filter((item) => !idSet.has(item.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replaceItemById = <T extends { id: number }>(
|
||||||
|
items: T[],
|
||||||
|
nextItem: T,
|
||||||
|
): T[] => {
|
||||||
|
return items.map((item) => (item.id === nextItem.id ? nextItem : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertItemById = <T extends { id: number }>(
|
||||||
|
items: T[],
|
||||||
|
nextItem: T,
|
||||||
|
options?: { prepend?: boolean },
|
||||||
|
): T[] => {
|
||||||
|
const existingIndex = items.findIndex((item) => item.id === nextItem.id);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
return replaceItemById(items, nextItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.prepend) {
|
||||||
|
return [nextItem, ...items];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...items, nextItem];
|
||||||
|
};
|
||||||
Vendored
+1
@@ -2,6 +2,7 @@
|
|||||||
/// <reference types="vite-plugin-pwa/client" />
|
/// <reference types="vite-plugin-pwa/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE?: string;
|
||||||
readonly VITE_APP_VERSION?: string;
|
readonly VITE_APP_VERSION?: string;
|
||||||
readonly VITE_GITHUB_REPO?: string;
|
readonly VITE_GITHUB_REPO?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user