mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f46b2b4d86 | |||
| 9f17d63cdc | |||
| 92f8ec47db | |||
| a97484cd9b | |||
| ee6bc8c50e | |||
| c94ab84ab9 | |||
| 84a03215f4 | |||
| def93749eb | |||
| 945a1c0dfc | |||
| d752e096a3 | |||
| 880a3b81b0 | |||
| 191aface2e | |||
| e121dadb90 | |||
| bafcfbde3a | |||
| 98c463c62b | |||
| daf34d0f6c | |||
| bb505d461d | |||
| 00be0ac31e | |||
| fc5624a190 | |||
| 42ae3457b5 | |||
| 088027da7b | |||
| d37adee5df | |||
| c147e52d72 | |||
| d483258eef | |||
| 79c28103d5 | |||
| f36bf1437c | |||
| 4ad3aa2c06 | |||
| 357a4b165e | |||
| a15be253f5 | |||
| 572d1c16a6 | |||
| 39e22c07de | |||
| ca24573803 | |||
| 0cb3263a2e | |||
| fb2189c924 | |||
| 1383174b31 | |||
| c95bde7055 | |||
| 57f5e3a1a3 | |||
| 66ad52c199 | |||
| 2081dc9658 | |||
| b93255df3d | |||
| e1aef8700e | |||
| d5b3a39774 | |||
| 022e9e3807 | |||
| d333d463f6 | |||
| abc9f21ab9 | |||
| d216567c02 | |||
| 45bfd35a20 |
@@ -268,3 +268,6 @@ sql/
|
||||
!go-backend/internal/store/postgres/sql/
|
||||
!go-backend/internal/store/postgres/sql/schema.sql
|
||||
!go-backend/internal/store/postgres/sql/data.sql
|
||||
go-backend/gost.db-shm
|
||||
.gitignore
|
||||
go-backend/gost.db-wal
|
||||
|
||||
@@ -360,6 +360,8 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
return nil, errors.New("隧道配置不完整")
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
|
||||
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
|
||||
nodeCache := map[int64]*nodeRecord{}
|
||||
@@ -383,7 +385,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
"toInx": firstNode.Inx,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -391,7 +393,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 3,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,7 +408,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 2,
|
||||
"toInx": nextNode.Inx,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -415,7 +417,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 3,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,7 +481,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
case 1:
|
||||
for _, inNode := range inNodes {
|
||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
})
|
||||
}
|
||||
@@ -532,14 +534,14 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
|
||||
for _, outNode := range outNodes {
|
||||
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 3,
|
||||
})
|
||||
}
|
||||
default:
|
||||
for _, inNode := range inNodes {
|
||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -766,6 +766,37 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
usedPorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, port := range usedPorts {
|
||||
if port == req.RemotePort {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runtimeOnPort, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, req.RemotePort)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if runtimeOnPort != nil {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
existsOnNodePort, err := h.repo.ExistsActivePeerShareRuntimeOnNodePort(share.NodeID, req.RemotePort)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if existsOnNodePort {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
tunnelID, err := h.repo.CreateFederationTunnel(
|
||||
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
|
||||
@@ -780,6 +811,30 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
runtime := &repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
ResourceKey: fmt.Sprintf("federation-forward-%d-%d-%d", share.ID, tunnelID, req.RemotePort),
|
||||
BindingID: "",
|
||||
Role: "forward",
|
||||
ChainName: "",
|
||||
ServiceName: "",
|
||||
Protocol: defaultString(req.Protocol, "tcp"),
|
||||
Strategy: "fifo",
|
||||
Port: req.RemotePort,
|
||||
Target: strings.TrimSpace(req.Target),
|
||||
Applied: 0,
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
|
||||
_ = h.deleteTunnelByID(tunnelID)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
@@ -1211,9 +1266,83 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
|
||||
h.bindPeerShareForwardRuntimeServices(share, req.Data)
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
}
|
||||
|
||||
type federationForwardServiceBinding struct {
|
||||
Name string
|
||||
Port int
|
||||
}
|
||||
|
||||
func parseFederationForwardServiceBindings(data interface{}) []federationForwardServiceBinding {
|
||||
dataMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rawServices, ok := dataMap["services"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
serviceList, ok := rawServices.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
|
||||
for _, item := range serviceList {
|
||||
svcMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(asString(svcMap["addr"]))
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
continue
|
||||
}
|
||||
bindings = append(bindings, federationForwardServiceBinding{Name: name, Port: port})
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
|
||||
if h == nil || h.repo == nil || share == nil {
|
||||
return
|
||||
}
|
||||
bindings := parseFederationForwardServiceBindings(data)
|
||||
if len(bindings) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, binding := range bindings {
|
||||
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
|
||||
if err != nil || runtime == nil || runtime.Status != 1 {
|
||||
continue
|
||||
}
|
||||
if runtime.ServiceName == binding.Name && runtime.Applied == 1 {
|
||||
continue
|
||||
}
|
||||
_ = h.repo.UpdatePeerShareRuntimeServiceName(runtime.ID, binding.Name, now)
|
||||
}
|
||||
}
|
||||
|
||||
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
|
||||
|
||||
@@ -414,6 +414,262 @@ func TestFederationShareResetFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationTunnelCreateCreatesPeerShareRuntime(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-forward-node", "federation-forward-secret", "10.90.80.70", "10.90.80.70", "", "24000-24020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "federation-forward-node")
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "federation-forward-share",
|
||||
NodeID: nodeID,
|
||||
Token: "federation-forward-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 24000,
|
||||
PortRangeEnd: 24020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("federation-forward-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(federationTunnelRequest{
|
||||
Protocol: "tcp",
|
||||
RemotePort: 24001,
|
||||
Target: "1.1.1.1:443",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+share.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationTunnelCreate(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 24001)
|
||||
if runtimeCount != 1 {
|
||||
t.Fatalf("expected 1 runtime row for new federation forward tunnel, got %d", runtimeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationTunnelCreateRejectsOccupiedPort(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-port-check-node", "federation-port-check-secret", "10.91.80.70", "10.91.80.70", "", "24100-24120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "federation-port-check-node")
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "federation-port-check-share",
|
||||
NodeID: nodeID,
|
||||
Token: "federation-port-check-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 24100,
|
||||
PortRangeEnd: 24120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
|
||||
create := func() response.R {
|
||||
body, err := json.Marshal(federationTunnelRequest{Protocol: "tcp", RemotePort: 24101, Target: "1.1.1.1:443"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer federation-port-check-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
h.federationTunnelCreate(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
first := create()
|
||||
if first.Code != 0 {
|
||||
t.Fatalf("expected first create success, got %d (%s)", first.Code, first.Msg)
|
||||
}
|
||||
|
||||
second := create()
|
||||
if second.Code != 403 {
|
||||
t.Fatalf("expected second create to be rejected with 403, got %d (%s)", second.Code, second.Msg)
|
||||
}
|
||||
if second.Msg != "Port already in use" {
|
||||
t.Fatalf("expected occupied port message, got %q", second.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTunnelReleasesFederationForwardRuntimeByPort(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "delete-forward-share",
|
||||
NodeID: 1,
|
||||
Token: "delete-forward-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 25000,
|
||||
PortRangeEnd: 25020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("delete-forward-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 25001)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, tunnelName, 1.0, 1, "tcp", 1, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "del-r1", "del-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 25001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
if err := h.deleteTunnelByID(1); err != nil {
|
||||
t.Fatalf("delete tunnel: %v", err)
|
||||
}
|
||||
|
||||
activeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 25001)
|
||||
if activeCount != 0 {
|
||||
t.Fatalf("expected runtime released after tunnel delete, active rows=%d", activeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-forward-role-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-forward-role-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26000,
|
||||
PortRangeEnd: 26020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-forward-role-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
1, share.ID, share.NodeID, "bind-r1", "bind-rk1", "", "forward", "", "", "tcp", "fifo", 26001, "", 0, 1, now, now,
|
||||
2, share.ID, share.NodeID, "bind-r2", "bind-rk2", "", "middle", "", "", "tcp", "round", 26002, "", 0, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert runtimes: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{
|
||||
map[string]interface{}{"name": "77_2_10_tcp", "addr": "[::]:26001"},
|
||||
map[string]interface{}{"name": "88_2_10_tcp", "addr": "[::]:26002"},
|
||||
},
|
||||
})
|
||||
|
||||
forwardServiceName := ""
|
||||
middleServiceName := ""
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
|
||||
t.Fatalf("load forward runtime service name: %v", err)
|
||||
}
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 2`).Scan(&middleServiceName).Error; err != nil {
|
||||
t.Fatalf("load middle runtime service name: %v", err)
|
||||
}
|
||||
|
||||
if forwardServiceName != "77_2_10" {
|
||||
t.Fatalf("expected forward runtime service name bound, got %q", forwardServiceName)
|
||||
}
|
||||
if middleServiceName != "" {
|
||||
t.Fatalf("expected non-forward runtime unchanged, got %q", middleServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageList(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -40,6 +40,7 @@ func (h *Handler) processFlowItem(item flowItem) {
|
||||
if ok {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
h.processPeerShareFlowFromForward(forwardID, serviceName, item)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
@@ -87,6 +88,45 @@ func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
|
||||
return runtimeID, true
|
||||
}
|
||||
|
||||
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
|
||||
tunnelName = strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(tunnelName, "Share-") {
|
||||
return 0, 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(tunnelName, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
|
||||
port, err := strconv.Atoi(portValue)
|
||||
if err != nil || port <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return shareID, port, true
|
||||
}
|
||||
|
||||
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
|
||||
tunnelName = strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(tunnelName, "Share-") {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(tunnelName, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return shareID, true
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
if h == nil || h.repo == nil || runtimeID <= 0 {
|
||||
return
|
||||
@@ -113,6 +153,95 @@ func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || forwardID <= 0 {
|
||||
return
|
||||
}
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
// Forward not found in local database - might be a federation port-forward
|
||||
// Try to find by service name in peer_share_runtime
|
||||
h.processPeerShareFlowByServiceName(serviceName, item)
|
||||
return
|
||||
}
|
||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||
if err != nil || tunnel == nil {
|
||||
return
|
||||
}
|
||||
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_ = h.repo.AddPeerShareCurrentFlow(shareID, delta)
|
||||
|
||||
share, err := h.repo.GetPeerShare(shareID)
|
||||
if err != nil || share == nil {
|
||||
return
|
||||
}
|
||||
if !isPeerShareFlowExceeded(share) {
|
||||
return
|
||||
}
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func normalizeForwardRuntimeServiceName(serviceName string) string {
|
||||
name := strings.TrimSpace(serviceName)
|
||||
if strings.HasSuffix(name, "_tcp") {
|
||||
return strings.TrimSuffix(name, "_tcp")
|
||||
}
|
||||
if strings.HasSuffix(name, "_udp") {
|
||||
return strings.TrimSuffix(name, "_udp")
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowByServiceName(serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
normalized := normalizeForwardRuntimeServiceName(serviceName)
|
||||
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(runtimes) == 0 && normalized != serviceName {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(runtimes) != 1 {
|
||||
return
|
||||
}
|
||||
runtime := runtimes[0]
|
||||
|
||||
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
|
||||
|
||||
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil || matchedShare == nil {
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(matchedShare) {
|
||||
h.enforcePeerShareFlowLimit(matchedShare.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
@@ -271,11 +400,34 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
|
||||
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
|
||||
for _, serviceName := range runtimeServiceNames {
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if serviceName == "" {
|
||||
continue
|
||||
}
|
||||
runtimeServiceSet[serviceName] = struct{}{}
|
||||
}
|
||||
|
||||
for _, item := range services {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" || name == "web_api" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, "fed_svc_") {
|
||||
continue
|
||||
}
|
||||
normalizedName := normalizeForwardRuntimeServiceName(name)
|
||||
if _, ok := runtimeServiceSet[normalizedName]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := runtimeServiceSet[name]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(name, "_")
|
||||
if len(parts) >= 3 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -61,3 +62,240 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "forward-share",
|
||||
NodeID: 1,
|
||||
Token: "forward-share-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 30000,
|
||||
PortRangeEnd: 30010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("forward-share-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'u2', 'x', 1, ?, 99999, 0, 0, 1, 1, ?, ?, 1)
|
||||
`, now+24*60*60*1000, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := "Share-" + strconv.FormatInt(share.ID, 10) + "-Port-30001"
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, ?, 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, tunnelName, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 1, 99999, 0, 0, 1, ?, 1)
|
||||
`, now+24*60*60*1000).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(flowItem{N: "20_2_10", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-service.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "forward-service-share",
|
||||
NodeID: 1,
|
||||
Token: "forward-service-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31000,
|
||||
PortRangeEnd: 31010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("forward-service-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(flowItem{N: "20_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "ambiguous-share-a",
|
||||
NodeID: 1,
|
||||
Token: "ambiguous-token-a",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31100,
|
||||
PortRangeEnd: 31110,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share A: %v", err)
|
||||
}
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "ambiguous-share-b",
|
||||
NodeID: 1,
|
||||
Token: "ambiguous-token-b",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31200,
|
||||
PortRangeEnd: 31210,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share B: %v", err)
|
||||
}
|
||||
shareA, _ := r.GetPeerShareByToken("ambiguous-token-a")
|
||||
shareB, _ := r.GetPeerShareByToken("ambiguous-token-b")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
shareA.ID, 1, "amb-r1", "amb-rk1", "", "forward", "", "99_2_10", "tcp", "fifo", 31101, "", 1, 1, now, now,
|
||||
shareB.ID, 1, "amb-r2", "amb-rk2", "", "forward", "", "99_2_10", "tcp", "fifo", 31201, "", 1, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert ambiguous runtimes: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(flowItem{N: "99_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedA, _ := r.GetPeerShare(shareA.ID)
|
||||
updatedB, _ := r.GetPeerShare(shareB.ID)
|
||||
if updatedA.CurrentFlow != 0 || updatedB.CurrentFlow != 0 {
|
||||
t.Fatalf("expected ambiguous service flow to be skipped, got shareA=%d shareB=%d", updatedA.CurrentFlow, updatedB.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "cleanup-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "cleanup-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31300,
|
||||
PortRangeEnd: 31310,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("cleanup-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "cleanup-r1", "cleanup-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31301, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip active shared runtime service; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-fed-svc.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip fed_svc_ service names; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ type Handler struct {
|
||||
jobsCancel context.CancelFunc
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -70,12 +73,15 @@ type flowItem struct {
|
||||
}
|
||||
|
||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
return &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
captchaTokens: make(map[string]int64),
|
||||
h := &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
captchaTokens: make(map[string]int64),
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
}
|
||||
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) WebSocketHandler() http.Handler {
|
||||
@@ -213,7 +219,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if captchaEnabled {
|
||||
if captchaEnabled && !h.apiClientCaptchaBypassEnabled(r) {
|
||||
captchaID := strings.TrimSpace(req.CaptchaID)
|
||||
if captchaID == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
@@ -988,6 +994,20 @@ func (h *Handler) captchaEnabled() (bool, error) {
|
||||
return strings.EqualFold(cfg.Value, "true"), nil
|
||||
}
|
||||
|
||||
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client := strings.ToLower(strings.TrimSpace(r.Header.Get("X-FLVX-API-Client")))
|
||||
switch client {
|
||||
case "whmcs", "whmcs-module":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) markCaptchaToken(token string) {
|
||||
if h == nil {
|
||||
return
|
||||
|
||||
@@ -355,11 +355,28 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
id := idFromBody(r, w)
|
||||
if id <= 0 {
|
||||
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
secret, err := h.repo.GetNodeSecret(id)
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
version, err := resolveLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := h.repo.GetNodeSecret(req.ID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("节点不存在"))
|
||||
return
|
||||
@@ -373,7 +390,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
|
||||
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
|
||||
response.WriteJSON(w, response.OK(cmd))
|
||||
}
|
||||
|
||||
@@ -901,6 +918,58 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tunnel.Type == 2 {
|
||||
h.cleanupTunnelRuntime(tunnelID)
|
||||
h.cleanupFederationRuntime(tunnelID)
|
||||
state, err := h.reconstructTunnelState(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
|
||||
if fedErr != nil {
|
||||
return fedErr
|
||||
}
|
||||
tx := h.repo.BeginTx()
|
||||
if tx.Error != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
return tx.Error
|
||||
}
|
||||
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
|
||||
tx.Rollback()
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
return replaceErr
|
||||
}
|
||||
if commitErr := tx.Commit().Error; commitErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
return commitErr
|
||||
}
|
||||
_, _, applyErr := h.applyTunnelRuntime(state)
|
||||
if applyErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
||||
return applyErr
|
||||
}
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range forwards {
|
||||
if err := h.syncForwardServices(&forwards[i], "UpdateService", true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||
ids := idsFromBody(r, w)
|
||||
if ids == nil {
|
||||
@@ -909,72 +978,11 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||
success := 0
|
||||
fail := 0
|
||||
for _, tunnelID := range ids {
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
|
||||
if tunnel.Type == 2 {
|
||||
h.cleanupTunnelRuntime(tunnelID)
|
||||
h.cleanupFederationRuntime(tunnelID)
|
||||
state, err := h.reconstructTunnelState(tunnelID)
|
||||
if err != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
|
||||
if fedErr != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
tx := h.repo.BeginTx()
|
||||
if tx.Error != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
|
||||
tx.Rollback()
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if commitErr := tx.Commit().Error; commitErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
_, _, applyErr := h.applyTunnelRuntime(state)
|
||||
if applyErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if len(forwards) == 0 {
|
||||
success++
|
||||
continue
|
||||
}
|
||||
ok := true
|
||||
for i := range forwards {
|
||||
if err := h.syncForwardServices(&forwards[i], "UpdateService", true); err != nil {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
success++
|
||||
} else {
|
||||
fail++
|
||||
}
|
||||
success++
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
|
||||
}
|
||||
@@ -2786,7 +2794,21 @@ func (h *Handler) deleteNodeByID(id int64) error {
|
||||
}
|
||||
|
||||
func (h *Handler) deleteTunnelByID(id int64) error {
|
||||
return h.repo.DeleteTunnelCascade(id)
|
||||
if h == nil || h.repo == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
|
||||
tunnelName, _ := h.repo.GetTunnelName(id)
|
||||
if err := h.repo.DeleteTunnelCascade(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardByID(id int64) error {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -19,8 +20,104 @@ const (
|
||||
githubHTMLBase = "https://github.com"
|
||||
upgradeTimeout = 5 * time.Minute
|
||||
batchWorkers = 5
|
||||
|
||||
releaseChannelStable = "stable"
|
||||
releaseChannelDev = "dev"
|
||||
)
|
||||
|
||||
var (
|
||||
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
|
||||
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
|
||||
)
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
|
||||
func normalizeReleaseChannel(channel string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(channel)) {
|
||||
case releaseChannelDev:
|
||||
return releaseChannelDev
|
||||
default:
|
||||
return releaseChannelStable
|
||||
}
|
||||
}
|
||||
|
||||
func releaseChannelFromTag(tag string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(tag))
|
||||
if normalized == "" {
|
||||
return releaseChannelDev
|
||||
}
|
||||
if testKeywordPattern.MatchString(normalized) {
|
||||
return releaseChannelDev
|
||||
}
|
||||
if stableVersionPattern.MatchString(normalized) {
|
||||
return releaseChannelStable
|
||||
}
|
||||
|
||||
return releaseChannelDev
|
||||
}
|
||||
|
||||
func releaseChannelLabel(channel string) string {
|
||||
if normalizeReleaseChannel(channel) == releaseChannelDev {
|
||||
return "测试版"
|
||||
}
|
||||
|
||||
return "正式版"
|
||||
}
|
||||
|
||||
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var releases []githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func resolveLatestReleaseByChannel(channel string) (string, error) {
|
||||
normalizedChannel := normalizeReleaseChannel(channel)
|
||||
releases, err := fetchGitHubReleases(50)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, r := range releases {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(r.TagName)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if releaseChannelFromTag(tag) == normalizedChannel {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
||||
}
|
||||
|
||||
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -30,6 +127,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
@@ -40,12 +138,13 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = resolveLatestRelease()
|
||||
version, err = resolveLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -67,6 +166,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err)))
|
||||
return
|
||||
}
|
||||
h.markNodePendingUpgradeRedeploy(req.ID)
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"version": version,
|
||||
@@ -75,61 +175,11 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func resolveLatestRelease() (string, error) {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求GitHub失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
|
||||
return resolveLatestReleaseAPI()
|
||||
}
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return resolveLatestReleaseAPI()
|
||||
}
|
||||
|
||||
parts := strings.Split(location, "/")
|
||||
tag := parts[len(parts)-1]
|
||||
if tag == "" || tag == "latest" {
|
||||
return resolveLatestReleaseAPI()
|
||||
}
|
||||
|
||||
return tag, nil
|
||||
return resolveLatestReleaseByChannel(releaseChannelStable)
|
||||
}
|
||||
|
||||
func resolveLatestReleaseAPI() (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求GitHub API失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(release.TagName) == "" {
|
||||
return "", fmt.Errorf("无法从GitHub获取最新版本号")
|
||||
}
|
||||
|
||||
return release.TagName, nil
|
||||
return resolveLatestReleaseByChannel(releaseChannelStable)
|
||||
}
|
||||
|
||||
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -141,6 +191,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
@@ -151,12 +202,13 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = resolveLatestRelease()
|
||||
version, err = resolveLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -195,6 +247,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()}
|
||||
return
|
||||
}
|
||||
h.markNodePendingUpgradeRedeploy(nodeID)
|
||||
results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message}
|
||||
}(i, id)
|
||||
}
|
||||
@@ -212,37 +265,28 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20")
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
|
||||
releases, err := fetchGitHubReleases(50)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
|
||||
return
|
||||
}
|
||||
|
||||
var releases []struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
type releaseItem struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
items := make([]releaseItem, 0, len(releases))
|
||||
@@ -250,11 +294,20 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(r.TagName)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
itemChannel := releaseChannelFromTag(tag)
|
||||
if itemChannel != channel {
|
||||
continue
|
||||
}
|
||||
items = append(items, releaseItem{
|
||||
Version: r.TagName,
|
||||
Version: tag,
|
||||
Name: r.Name,
|
||||
PublishedAt: r.PublishedAt,
|
||||
Prerelease: r.Prerelease,
|
||||
Prerelease: itemChannel == releaseChannelDev,
|
||||
Channel: itemChannel,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -289,3 +342,66 @@ func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) {
|
||||
"message": result.Message,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) markNodePendingUpgradeRedeploy(nodeID int64) {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
h.upgradeMu.Lock()
|
||||
h.pendingUpgradeRedeploy[nodeID] = struct{}{}
|
||||
h.upgradeMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return false
|
||||
}
|
||||
h.upgradeMu.Lock()
|
||||
_, ok := h.pendingUpgradeRedeploy[nodeID]
|
||||
if ok {
|
||||
delete(h.pendingUpgradeRedeploy, nodeID)
|
||||
}
|
||||
h.upgradeMu.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *Handler) onNodeOnline(nodeID int64) {
|
||||
if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
|
||||
return
|
||||
}
|
||||
h.redeployNodeRuntimeAfterUpgrade(nodeID)
|
||||
}
|
||||
|
||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
||||
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
}
|
||||
forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
}
|
||||
|
||||
tunnelFailed := make(map[int64]struct{})
|
||||
for _, tunnelID := range tunnelIDs {
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
||||
tunnelFailed[tunnelID] = struct{}{}
|
||||
fmt.Printf("post-upgrade redeploy: tunnel %d failed on node %d: %v\n", tunnelID, nodeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, forwardID := range forwardIDs {
|
||||
forward, getErr := h.getForwardRecord(forwardID)
|
||||
if getErr != nil || forward == nil {
|
||||
continue
|
||||
}
|
||||
if _, skipped := tunnelFailed[forward.TunnelID]; skipped {
|
||||
continue
|
||||
}
|
||||
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: forward %d failed on node %d: %v\n", forwardID, nodeID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReleaseChannelFromTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tag string
|
||||
expects string
|
||||
}{
|
||||
{name: "stable semantic version", tag: "2.1.4", expects: releaseChannelStable},
|
||||
{name: "v prefix should be dev", tag: "v2.1.4", expects: releaseChannelDev},
|
||||
{name: "rc release", tag: "2.1.4-rc2", expects: releaseChannelDev},
|
||||
{name: "beta release", tag: "2.1.4-beta.1", expects: releaseChannelDev},
|
||||
{name: "alpha release", tag: "2.1.4-alpha", expects: releaseChannelDev},
|
||||
{name: "non numeric tag", tag: "nightly", expects: releaseChannelDev},
|
||||
{name: "empty tag", tag: "", expects: releaseChannelDev},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := releaseChannelFromTag(tc.tag); got != tc.expects {
|
||||
t.Fatalf("releaseChannelFromTag(%q) = %q, want %q", tc.tag, got, tc.expects)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeReleaseChannel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expects string
|
||||
}{
|
||||
{input: "", expects: releaseChannelStable},
|
||||
{input: "stable", expects: releaseChannelStable},
|
||||
{input: "dev", expects: releaseChannelDev},
|
||||
{input: "DEV", expects: releaseChannelDev},
|
||||
{input: "preview", expects: releaseChannelStable},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := normalizeReleaseChannel(tc.input); got != tc.expects {
|
||||
t.Fatalf("normalizeReleaseChannel(%q) = %q, want %q", tc.input, got, tc.expects)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1270,6 +1270,98 @@ func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var items []model.PeerShareRuntime
|
||||
err := r.db.Where("service_name = ? AND status = 1 AND role = ?", serviceName, "forward").
|
||||
Order("id ASC").
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = make([]model.PeerShareRuntime, 0)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var names []string
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward").
|
||||
Pluck("service_name", &names).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if names == nil {
|
||||
names = make([]string, 0)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var item model.PeerShareRuntime
|
||||
err := r.db.Where("share_id = ? AND port = ? AND status = 1 AND role = ?", shareID, port, "forward").First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND port = ? AND status = 1", nodeID, port).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"service_name": serviceName,
|
||||
"applied": 1,
|
||||
"updated_time": updatedTime,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if shareID <= 0 || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
if updatedTime <= 0 {
|
||||
updatedTime = unixMilliNow()
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("share_id = ? AND port = ? AND status = 1", shareID, port).Updates(map[string]interface{}{
|
||||
"status": 0,
|
||||
"applied": 0,
|
||||
"service_name": "",
|
||||
"updated_time": updatedTime,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ─── FederationTunnelBinding ─────────────────────────────────────────
|
||||
|
||||
func (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {
|
||||
|
||||
@@ -56,6 +56,40 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.ChainTunnel{}).
|
||||
Joins("JOIN tunnel ON tunnel.id = chain_tunnel.tunnel_id").
|
||||
Where("chain_tunnel.node_id = ? AND tunnel.status = 1", nodeID).
|
||||
Select("DISTINCT chain_tunnel.tunnel_id").
|
||||
Order("chain_tunnel.tunnel_id ASC").
|
||||
Pluck("chain_tunnel.tunnel_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Joins("JOIN forward ON forward.id = forward_port.forward_id").
|
||||
Where("forward_port.node_id = ? AND forward.status = 1", nodeID).
|
||||
Select("DISTINCT forward_port.forward_id").
|
||||
Order("forward_port.forward_id ASC").
|
||||
Pluck("forward_port.forward_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
|
||||
@@ -68,9 +68,10 @@ type CommandResult struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
upgrader websocket.Upgrader
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
upgrader websocket.Upgrader
|
||||
onNodeOnline func(nodeID int64)
|
||||
|
||||
mu sync.RWMutex
|
||||
admins map[*connWrap]struct{}
|
||||
@@ -79,6 +80,15 @@ type Server struct {
|
||||
pending map[string]pendingRequest
|
||||
}
|
||||
|
||||
func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.onNodeOnline = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func NewServer(repo *repo.Repository, jwtSecret string) *Server {
|
||||
return &Server{
|
||||
repo: repo,
|
||||
@@ -183,6 +193,13 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
_ = s.repo.UpdateNodeOnline(nodeID, 1, version, httpVal, tlsVal, socksVal)
|
||||
s.broadcastStatus(nodeID, 1)
|
||||
|
||||
s.mu.RLock()
|
||||
onlineHook := s.onNodeOnline
|
||||
s.mu.RUnlock()
|
||||
if onlineHook != nil {
|
||||
go onlineHook(nodeID)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
close(done)
|
||||
needOfflineBroadcast := false
|
||||
|
||||
@@ -193,6 +193,126 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-ip-pref-forward", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "diagnose-ip-pref-forward")
|
||||
|
||||
insertNode := func(name, v4, v6 string) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", v4, v4, v6, "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryNodeID := insertNode("entry-node-v6", "10.10.1.10", "2001:db8:10::10")
|
||||
chainNodeID := insertNode("chain-node-v6", "10.10.1.20", "2001:db8:10::20")
|
||||
exitNodeID := insertNode("exit-node-v6", "10.10.1.30", "2001:db8:10::30")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
|
||||
`, tunnelID, chainNodeID).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 2, "normal_user", "ip-pref-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "ip-pref-forward")
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+strconv.FormatInt(forwardID, 10)+`}`))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
payload, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload, got %T", out.Data)
|
||||
}
|
||||
results, ok := payload["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
t.Fatalf("expected non-empty results, got %v", payload["results"])
|
||||
}
|
||||
|
||||
hasEntryToChain := false
|
||||
hasChainToExit := false
|
||||
for _, raw := range results {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from := valueAsInt(item["fromChainType"])
|
||||
to := valueAsInt(item["toChainType"])
|
||||
targetIP := strings.TrimSpace(valueAsString(item["targetIp"]))
|
||||
|
||||
if from == 1 && to == 2 {
|
||||
hasEntryToChain = true
|
||||
if targetIP != "2001:db8:10::20" {
|
||||
t.Fatalf("expected entry->chain diagnosis target to use IPv6, got %q", targetIP)
|
||||
}
|
||||
}
|
||||
|
||||
if from == 2 && to == 3 {
|
||||
hasChainToExit = true
|
||||
if targetIP != "2001:db8:10::30" {
|
||||
t.Fatalf("expected chain->exit diagnosis target to use IPv6, got %q", targetIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEntryToChain || !hasChainToExit {
|
||||
t.Fatalf("expected entry->chain and chain->exit steps, got entry=%v chain=%v", hasEntryToChain, hasChainToExit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
|
||||
@@ -45,6 +45,18 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
assertCodeMsg(t, resp, -1, "验证码校验失败")
|
||||
})
|
||||
|
||||
t.Run("whmcs api client bypasses captcha", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-FLVX-API-Client", "whmcs")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCode(t, resp, 0)
|
||||
})
|
||||
|
||||
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
|
||||
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
|
||||
verifyReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_GITHUB_REPO=https://github.com/Sagit-chu/flvx
|
||||
VITE_APP_VERSION=beta
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 114 KiB |
@@ -2,7 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="FLVX" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<title>FLVX</title>
|
||||
<script>
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
@@ -25,8 +27,6 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@react-aria/i18n": "^3.12.12",
|
||||
"@react-aria/visually-hidden": "3.8.25",
|
||||
"@react-types/shared": "3.30.0",
|
||||
@@ -43,8 +43,11 @@
|
||||
"react-dom": "18.3.1",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-is": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "6.30.3",
|
||||
"recharts": "^3.1.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwind-variants": "1.0.0",
|
||||
@@ -75,6 +78,7 @@
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "npm:rolldown-vite@^7.3.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite-tsconfig-paths": "^6.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
@@ -31,6 +31,8 @@ import axios from "axios";
|
||||
|
||||
import Network from "./network";
|
||||
|
||||
export type ReleaseChannel = "stable" | "dev";
|
||||
|
||||
// 登陆相关接口
|
||||
export interface LoginData {
|
||||
username: string;
|
||||
@@ -66,8 +68,10 @@ export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
||||
export const updateNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/update", data);
|
||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||
export const getNodeInstallCommand = (id: number) =>
|
||||
Network.post<string>("/node/install", { id });
|
||||
export const getNodeInstallCommand = (
|
||||
id: number,
|
||||
channel: ReleaseChannel = "stable",
|
||||
) => Network.post<string>("/node/install", { id, channel });
|
||||
export const updateNodeOrder = (data: {
|
||||
nodes: Array<{ id: number; inx: number }>;
|
||||
}) => Network.post("/node/update-order", data);
|
||||
@@ -77,20 +81,28 @@ export const checkNodeStatus = (nodeId?: number) => {
|
||||
return Network.post("/node/check-status", params);
|
||||
};
|
||||
|
||||
export const upgradeNode = (id: number, version?: string) =>
|
||||
export const upgradeNode = (
|
||||
id: number,
|
||||
version?: string,
|
||||
channel: ReleaseChannel = "stable",
|
||||
) =>
|
||||
Network.post(
|
||||
"/node/upgrade",
|
||||
{ id, version: version || "" },
|
||||
{ id, version: version || "", channel },
|
||||
{ timeout: 5 * 60 * 1000 },
|
||||
);
|
||||
export const batchUpgradeNodes = (ids: number[], version?: string) =>
|
||||
export const batchUpgradeNodes = (
|
||||
ids: number[],
|
||||
version?: string,
|
||||
channel: ReleaseChannel = "stable",
|
||||
) =>
|
||||
Network.post(
|
||||
"/node/batch-upgrade",
|
||||
{ ids, version: version || "" },
|
||||
{ ids, version: version || "", channel },
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
);
|
||||
export const getNodeReleases = () =>
|
||||
Network.post<NodeReleaseApiItem[]>("/node/releases");
|
||||
export const getNodeReleases = (channel: ReleaseChannel = "stable") =>
|
||||
Network.post<NodeReleaseApiItem[]>("/node/releases", { channel });
|
||||
export const rollbackNode = (id: number) =>
|
||||
Network.post("/node/rollback", { id });
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ export interface NodeReleaseApiItem {
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
channel: "stable" | "dev";
|
||||
}
|
||||
|
||||
export interface UserPackageInfoApiData {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Wraps page content with a smooth fade-in + slide-up entrance animation.
|
||||
* Use this as the outermost wrapper inside each page component.
|
||||
*/
|
||||
export const AnimatedPage = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={className}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Stagger container — apply to the parent of a list/grid of animated items.
|
||||
* Children should use `staggerItem` as their `variants` prop.
|
||||
*/
|
||||
export const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: 0.05,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual stagger item variant — fade-in + slide-up.
|
||||
*/
|
||||
export const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience wrapper for a stagger list.
|
||||
* Renders a `motion.div` (or `motion.ul/motion.tbody`) with stagger behaviour.
|
||||
*/
|
||||
export const StaggerList = ({
|
||||
children,
|
||||
className,
|
||||
as = "div",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
as?: "div" | "ul" | "tbody";
|
||||
}) => {
|
||||
const Component = motion[as] as React.ElementType;
|
||||
|
||||
return (
|
||||
<Component
|
||||
animate="show"
|
||||
className={className}
|
||||
initial="hidden"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual animated item for use inside StaggerList.
|
||||
*/
|
||||
export const StaggerItem = ({
|
||||
children,
|
||||
className,
|
||||
as = "div",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
as?: "div" | "li" | "tr";
|
||||
}) => {
|
||||
const Component = motion[as] as React.ElementType;
|
||||
|
||||
return (
|
||||
<Component className={className} variants={staggerItem}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple fade-in animation for standalone elements (chips, badges, counters, etc.)
|
||||
* Uses opacity + translateY to avoid font blurriness caused by scale transforms.
|
||||
*/
|
||||
export const FadeIn = ({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={className}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.2, delay, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { SearchIcon } from "@/components/icons";
|
||||
|
||||
interface SearchBarProps {
|
||||
isVisible: boolean;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
isVisible,
|
||||
value,
|
||||
placeholder = "搜索",
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
// Fixed h-8 so the container never changes height — eliminates the vertical jitter
|
||||
<div className="flex items-center gap-2 h-8 overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{!isVisible ? (
|
||||
<motion.div
|
||||
key="search-btn"
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="搜索"
|
||||
className="text-default-600"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={onOpen}
|
||||
>
|
||||
<SearchIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="search-input"
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex w-full items-center gap-2"
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input:
|
||||
"bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
|
||||
inputWrapper: "bg-default-100 border-0 shadow-none h-8 min-h-8",
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onChange("");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors transition-transform duration-100 active:scale-95 disabled:active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -11,14 +11,14 @@ function Checkbox({
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="checkbox"
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className="flex items-center justify-center text-current"
|
||||
className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150"
|
||||
data-slot="checkbox-indicator"
|
||||
>
|
||||
<CheckIcon className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -57,7 +57,7 @@ function DialogContent({
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg dark:bg-default-50",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-4 data-[state=closed]:slide-out-to-bottom-2 sm:rounded-lg dark:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
|
||||
@@ -85,7 +85,7 @@ function DropdownMenuSubContent({
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
className={cn(
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
@@ -103,7 +103,7 @@ function DropdownMenuContent({
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
className={cn(
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
|
||||
@@ -90,7 +90,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-default-200 bg-white text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-default-200 bg-white text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useState, type SetStateAction } from "react";
|
||||
|
||||
const readStoredValue = <T>(key: string, fallbackValue: T): T => {
|
||||
try {
|
||||
const rawValue = localStorage.getItem(key);
|
||||
|
||||
if (rawValue === null) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return JSON.parse(rawValue) as T;
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const useLocalStorageState = <T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
): readonly [T, (value: SetStateAction<T>) => void, () => void] => {
|
||||
const [value, setValue] = useState<T>(() =>
|
||||
readStoredValue(key, initialValue),
|
||||
);
|
||||
|
||||
const setPersistedValue = useCallback(
|
||||
(nextValue: SetStateAction<T>) => {
|
||||
setValue((prevValue) => {
|
||||
const resolvedValue =
|
||||
typeof nextValue === "function"
|
||||
? (nextValue as (value: T) => T)(prevValue)
|
||||
: nextValue;
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(resolvedValue));
|
||||
} catch {}
|
||||
|
||||
return resolvedValue;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const resetPersistedValue = useCallback(() => {
|
||||
setValue(initialValue);
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {}
|
||||
}, [initialValue, key]);
|
||||
|
||||
return [value, setPersistedValue, resetPersistedValue] as const;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
@@ -49,6 +50,9 @@ export default function AdminLayout({
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar_collapsed") === "true",
|
||||
);
|
||||
const [username, setUsername] = useState("");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
@@ -203,6 +207,14 @@ export default function AdminLayout({
|
||||
setMobileMenuVisible(false);
|
||||
};
|
||||
|
||||
// 切换折叠状态
|
||||
const toggleCollapse = () => {
|
||||
const newCollapsed = !isCollapsed;
|
||||
|
||||
setIsCollapsed(newCollapsed);
|
||||
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
|
||||
};
|
||||
|
||||
// 菜单点击处理
|
||||
const handleMenuClick = (path: string) => {
|
||||
navigate(path);
|
||||
@@ -303,62 +315,96 @@ export default function AdminLayout({
|
||||
className={`
|
||||
${isMobile ? "fixed" : "relative"}
|
||||
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
|
||||
${isMobile ? "w-64" : "w-72"}
|
||||
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-72"}
|
||||
bg-white dark:bg-black
|
||||
shadow-lg
|
||||
border-r border-gray-200 dark:border-gray-600
|
||||
z-50
|
||||
transition-transform duration-300 ease-in-out
|
||||
transition-all duration-300 ease-in-out
|
||||
flex flex-col
|
||||
${isMobile ? "h-screen" : "h-full"}
|
||||
${isMobile ? "top-0 left-0" : ""}
|
||||
`}
|
||||
>
|
||||
{/* Logo 区域 */}
|
||||
<div className="px-3 py-3 h-14 flex items-center">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Logo size={24} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
<p className="text-xs text-default-500">v{siteConfig.version}</p>
|
||||
</div>
|
||||
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
||||
<Logo size={28} />
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
|
||||
>
|
||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
v{siteConfig.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单导航 */}
|
||||
<nav className="flex-1 px-4 py-6 overflow-y-auto">
|
||||
<nav className="flex-1 px-3 py-6 overflow-y-auto overflow-x-hidden">
|
||||
<ul className="space-y-1">
|
||||
{filteredMenuItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<button
|
||||
<motion.button
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
|
||||
transition-colors duration-200 min-h-[44px]
|
||||
w-full flex items-center p-2 rounded-lg text-left
|
||||
relative min-h-[44px] overflow-hidden transition-colors
|
||||
${
|
||||
isActive
|
||||
? "bg-primary-100 dark:bg-primary-600/20 text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
? "text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200"
|
||||
}
|
||||
`}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={() => handleMenuClick(item.path)}
|
||||
>
|
||||
<div className="flex-shrink-0">{item.icon}</div>
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</button>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
|
||||
layoutId="sidebar-active"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 380,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isActive && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-lg bg-gray-100 dark:bg-gray-900 opacity-0"
|
||||
transition={{ duration: 0.15 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}
|
||||
>
|
||||
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 底部版权信息 */}
|
||||
<div className="px-4 py-2 pb-4 mt-auto flex-shrink-0">
|
||||
<div className="text-center">
|
||||
{/* 底部版权信息和折叠按钮 */}
|
||||
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}
|
||||
>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{" "}
|
||||
<a
|
||||
@@ -371,6 +417,49 @@ export default function AdminLayout({
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 桌面端折叠按钮 */}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={toggleCollapse}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
// 向右扩展的提示
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 5l7 7-7 7M5 5l7 7-7 7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
// 向左收起的提示
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -475,10 +564,19 @@ export default function AdminLayout({
|
||||
</header>
|
||||
|
||||
{/* 主内容 */}
|
||||
<main
|
||||
className={`flex-1 bg-gray-100 dark:bg-black ${isMobile ? "" : "overflow-y-auto"}`}
|
||||
>
|
||||
{children}
|
||||
<main className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="h-full"
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.22, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -115,10 +115,10 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
<main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
|
||||
|
||||
{/* 用于给固定 Tabbar 腾出空间的占位元素 */}
|
||||
<div aria-hidden className="h-16 safe-bottom" />
|
||||
<div aria-hidden className="h-[calc(4rem+var(--safe-area-bottom))]" />
|
||||
|
||||
{/* 底部Tabbar */}
|
||||
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 safe-bottom flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
|
||||
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
|
||||
{filteredTabItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
<button
|
||||
key={item.path}
|
||||
className={`
|
||||
flex flex-col items-center justify-center flex-1 h-full
|
||||
flex flex-col items-center justify-center flex-1 h-full pb-[var(--safe-area-bottom)]
|
||||
transition-colors duration-200 min-h-[44px]
|
||||
${
|
||||
isActive
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import App from "./App.tsx";
|
||||
import { Provider } from "./provider.tsx";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
registerSW({ immediate: true });
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<Provider>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -16,8 +18,6 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
updateConfigs,
|
||||
exportBackup,
|
||||
@@ -649,9 +649,9 @@ export default function ConfigPage() {
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||
<CardBody className="py-3">
|
||||
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm">
|
||||
<span className="text-sm font-medium">
|
||||
检测到配置变更,请记得保存您的修改
|
||||
</span>
|
||||
</div>
|
||||
@@ -702,13 +702,16 @@ export default function ConfigPage() {
|
||||
<Textarea
|
||||
label="公告内容"
|
||||
minRows={4}
|
||||
placeholder="请输入公告内容"
|
||||
placeholder="支持 Markdown,例如:**加粗**、[链接](https://example.com)、- 列表"
|
||||
value={announcement.content}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setAnnouncement({ ...announcement, content: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
公告支持 Markdown 语法,链接会在新标签页打开
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
@@ -524,7 +525,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
<AnimatedPage className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
{announcement && <AnnouncementBanner announcement={announcement} />}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
||||
<MetricCard
|
||||
@@ -978,6 +979,6 @@ export default function DashboardPage() {
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AnnouncementData } from "@/api";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
|
||||
interface AnnouncementBannerProps {
|
||||
@@ -14,13 +18,13 @@ export const AnnouncementBanner = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-100 dark:border-blue-500/20 bg-gradient-to-br from-blue-50/80 to-indigo-50/80 dark:from-blue-500/5 dark:to-indigo-500/5 shadow-sm">
|
||||
<CardBody className="p-4 sm:p-5">
|
||||
<div className="flex items-center gap-3.5 sm:gap-4">
|
||||
<div className="w-10 h-10 sm:w-11 sm:h-11 bg-white dark:bg-blue-500/10 rounded-full flex-shrink-0 flex items-center justify-center shadow-sm border border-blue-100 dark:border-blue-500/20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
className="w-5 h-5 sm:w-5 sm:h-5 text-blue-500 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
@@ -32,12 +36,57 @@ export const AnnouncementBanner = ({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
||||
<h3 className="text-[15px] font-semibold text-blue-900 dark:text-blue-100 tracking-wide mb-1 sm:mb-1.5 mt-1.5">
|
||||
公告
|
||||
</h3>
|
||||
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
||||
{announcement.content}
|
||||
</p>
|
||||
<div className="text-sm text-blue-800/90 dark:text-blue-100/90 break-words leading-relaxed">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => (
|
||||
<p className="mb-2 last:mb-0">{children}</p>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
className="underline decoration-blue-500/70 underline-offset-2 hover:text-blue-700 dark:hover:text-blue-100"
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-5 space-y-1 mb-2 last:mb-0">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-5 space-y-1 mb-2 last:mb-0">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-2 overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="mb-2 border-l-2 border-blue-300/80 dark:border-blue-500/60 pl-3 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{announcement.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
+676
-430
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -152,7 +153,12 @@ export default function IndexPage() {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<section className="flex flex-col items-center justify-center gap-4 py-4 sm:py-8 md:py-10 pb-20 min-h-[calc(100dvh-120px)] sm:min-h-[calc(100dvh-200px)]">
|
||||
<div className="w-full max-w-md px-4 sm:px-0">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md px-4 sm:px-0"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||
<h1 className={title({ size: "sm" })}>登陆</h1>
|
||||
@@ -203,7 +209,7 @@ export default function IndexPage() {
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
||||
|
||||
|
||||
+140
-130
@@ -1,6 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
AnimatedPage,
|
||||
StaggerList,
|
||||
StaggerItem,
|
||||
} from "@/components/animated-page";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
getTunnelList,
|
||||
} from "@/api";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
|
||||
interface SpeedLimitRule {
|
||||
id: number;
|
||||
@@ -51,6 +58,22 @@ export default function LimitPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"limit-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return rules;
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
|
||||
return rules.filter(
|
||||
(r) =>
|
||||
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}, [rules, searchKeyword]);
|
||||
|
||||
// 模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -219,10 +242,18 @@ export default function LimitPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
<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-1 max-w-sm flex items-center gap-2">
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索规则名称或绑定隧道"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
@@ -230,138 +261,117 @@ export default function LimitPage() {
|
||||
</div>
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
{rules.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{rules.map((rule) => (
|
||||
<Card
|
||||
key={rule.id}
|
||||
className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{rule.status === 1 ? "运行" : "异常"}
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
速度限制
|
||||
</span>
|
||||
<Chip color="secondary" size="sm" variant="flat">
|
||||
{rule.speed} Mbps
|
||||
{filteredRules.length > 0 ? (
|
||||
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{filteredRules.map((rule) => (
|
||||
<StaggerItem key={rule.id}>
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden h-full">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{rule.status === 1 ? "运行" : "异常"}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
绑定隧道
|
||||
</span>
|
||||
{rule.tunnelName ? (
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
{rule.tunnelName}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400 text-small">
|
||||
未绑定
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
速度限制
|
||||
</span>
|
||||
)}
|
||||
<Chip color="secondary" size="sm" variant="flat">
|
||||
{rule.speed} Mbps
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
绑定隧道
|
||||
</span>
|
||||
{rule.tunnelName ? (
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
{rule.tunnelName}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400 text-small">
|
||||
未绑定
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</div>
|
||||
</StaggerList>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无限速规则
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何限速规则,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无限速规则
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何限速规则,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
@@ -524,6 +534,6 @@ export default function LimitPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
batchUpgradeNodes,
|
||||
getNodeReleases,
|
||||
rollbackNode,
|
||||
type ReleaseChannel,
|
||||
} from "@/api";
|
||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
import {
|
||||
@@ -59,6 +62,7 @@ import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
|
||||
interface Node {
|
||||
@@ -126,11 +130,17 @@ const SortableItem = ({
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
const style: React.CSSProperties = {
|
||||
transform: transform
|
||||
? CSS.Transform.toString({
|
||||
...transform,
|
||||
x: Math.round(transform.x),
|
||||
y: Math.round(transform.y),
|
||||
})
|
||||
: undefined,
|
||||
transition: isDragging ? undefined : transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
willChange: "transform",
|
||||
willChange: isDragging ? "transform" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -149,6 +159,11 @@ export default function NodePage() {
|
||||
const [nodeList, setNodeList] = useState<Node[]>([]);
|
||||
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"node-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
@@ -183,6 +198,10 @@ export default function NodePage() {
|
||||
const [installCommandModal, setInstallCommandModal] = useState(false);
|
||||
const [installCommand, setInstallCommand] = useState("");
|
||||
const [currentNodeName, setCurrentNodeName] = useState("");
|
||||
const [installSelectorOpen, setInstallSelectorOpen] = useState(false);
|
||||
const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);
|
||||
const [installChannel, setInstallChannel] =
|
||||
useState<ReleaseChannel>("stable");
|
||||
|
||||
// 升级相关状态
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
@@ -198,9 +217,12 @@ export default function NodePage() {
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
channel: ReleaseChannel;
|
||||
}>
|
||||
>([]);
|
||||
const [releasesLoading, setReleasesLoading] = useState(false);
|
||||
const [releaseChannel, setReleaseChannel] =
|
||||
useState<ReleaseChannel>("stable");
|
||||
const [selectedVersion, setSelectedVersion] = useState("");
|
||||
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
|
||||
const [upgradeProgress, setUpgradeProgress] = useState<
|
||||
@@ -609,20 +631,31 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openInstallSelector = (node: Node) => {
|
||||
setInstallTargetNode(node);
|
||||
setInstallChannel("stable");
|
||||
setInstallSelectorOpen(true);
|
||||
};
|
||||
|
||||
// 复制安装命令
|
||||
const handleCopyInstallCommand = async (node: Node) => {
|
||||
const handleCopyInstallCommand = async (
|
||||
node: Node,
|
||||
channel: ReleaseChannel,
|
||||
) => {
|
||||
setNodeList((prev) =>
|
||||
prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await getNodeInstallCommand(node.id);
|
||||
const res = await getNodeInstallCommand(node.id, channel);
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
const copied = await tryCopyInstallCommand(res.data);
|
||||
|
||||
if (copied) {
|
||||
toast.success("安装命令已复制到剪贴板");
|
||||
toast.success(
|
||||
`${channel === "stable" ? "正式版" : "测试版"}安装命令已复制到剪贴板`,
|
||||
);
|
||||
} else {
|
||||
setInstallCommand(res.data);
|
||||
setCurrentNodeName(node.name);
|
||||
@@ -640,6 +673,13 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmInstallCommand = async () => {
|
||||
if (!installTargetNode) return;
|
||||
|
||||
setInstallSelectorOpen(false);
|
||||
await handleCopyInstallCommand(installTargetNode, installChannel);
|
||||
};
|
||||
|
||||
// 手动复制安装命令
|
||||
const handleManualCopy = async () => {
|
||||
try {
|
||||
@@ -651,18 +691,10 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 打开版本选择弹窗
|
||||
const openUpgradeModal = async (
|
||||
target: "single" | "batch",
|
||||
nodeId?: number,
|
||||
) => {
|
||||
setUpgradeTarget(target);
|
||||
setUpgradeTargetNodeId(nodeId || null);
|
||||
setSelectedVersion("");
|
||||
setUpgradeModalOpen(true);
|
||||
const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {
|
||||
setReleasesLoading(true);
|
||||
try {
|
||||
const res = await getNodeReleases();
|
||||
const res = await getNodeReleases(channel);
|
||||
|
||||
if (res.code === 0 && Array.isArray(res.data)) {
|
||||
setReleases(res.data);
|
||||
@@ -674,6 +706,21 @@ export default function NodePage() {
|
||||
} finally {
|
||||
setReleasesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 打开版本选择弹窗
|
||||
const openUpgradeModal = async (
|
||||
target: "single" | "batch",
|
||||
nodeId?: number,
|
||||
) => {
|
||||
const defaultChannel: ReleaseChannel = "stable";
|
||||
|
||||
setUpgradeTarget(target);
|
||||
setUpgradeTargetNodeId(nodeId || null);
|
||||
setReleaseChannel(defaultChannel);
|
||||
setSelectedVersion("");
|
||||
setUpgradeModalOpen(true);
|
||||
await loadReleasesByChannel(defaultChannel);
|
||||
};
|
||||
|
||||
// 确认升级(从版本弹窗)
|
||||
@@ -692,7 +739,11 @@ export default function NodePage() {
|
||||
),
|
||||
);
|
||||
try {
|
||||
const res = await upgradeNode(upgradeTargetNodeId, version);
|
||||
const res = await upgradeNode(
|
||||
upgradeTargetNodeId,
|
||||
version,
|
||||
releaseChannel,
|
||||
);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(`节点升级命令已发送,节点将自动重启`);
|
||||
@@ -712,7 +763,11 @@ export default function NodePage() {
|
||||
setBatchUpgradeLoading(true);
|
||||
setUpgradeModalOpen(false);
|
||||
try {
|
||||
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
|
||||
const res = await batchUpgradeNodes(
|
||||
Array.from(selectedIds),
|
||||
version,
|
||||
releaseChannel,
|
||||
);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
|
||||
@@ -953,7 +1008,21 @@ export default function NodePage() {
|
||||
const sortedNodes = useMemo((): Node[] => {
|
||||
if (!nodeList || nodeList.length === 0) return [];
|
||||
|
||||
const sortedByDb = [...nodeList].sort((a, b) => {
|
||||
let filteredNodes = nodeList;
|
||||
|
||||
if (searchKeyword.trim()) {
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
|
||||
filteredNodes = filteredNodes.filter(
|
||||
(n) =>
|
||||
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}
|
||||
|
||||
const sortedByDb = [...filteredNodes].sort((a, b) => {
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
|
||||
@@ -966,7 +1035,7 @@ export default function NodePage() {
|
||||
nodeOrder.length > 0 &&
|
||||
sortedByDb.every((n) => n.inx === undefined || n.inx === 0)
|
||||
) {
|
||||
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
|
||||
const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));
|
||||
const localSorted: Node[] = [];
|
||||
|
||||
nodeOrder.forEach((id) => {
|
||||
@@ -975,7 +1044,7 @@ export default function NodePage() {
|
||||
if (node) localSorted.push(node);
|
||||
});
|
||||
|
||||
nodeList.forEach((node) => {
|
||||
filteredNodes.forEach((node) => {
|
||||
if (!nodeOrder.includes(node.id)) {
|
||||
localSorted.push(node);
|
||||
}
|
||||
@@ -985,7 +1054,7 @@ export default function NodePage() {
|
||||
}
|
||||
|
||||
return sortedByDb;
|
||||
}, [nodeList, nodeOrder]);
|
||||
}, [nodeList, nodeOrder, searchKeyword]);
|
||||
|
||||
const sortableNodeIds = useMemo(
|
||||
() => sortedNodes.map((n) => n.id),
|
||||
@@ -993,69 +1062,94 @@ export default function NodePage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
color="default"
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
{selectMode ? "退出" : "批量"}
|
||||
</Button>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
<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-1 max-w-sm flex items-center gap-2">
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索节点名称或IP"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作浮动工具栏 */}
|
||||
{selectMode && selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
|
||||
<div className="flex min-w-max items-center gap-2">
|
||||
<span className="text-sm font-medium shrink-0">
|
||||
已选 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isLoading={batchUpgradeLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => openUpgradeModal("batch")}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
||||
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isDisabled={selectedIds.size === 0}
|
||||
isLoading={batchUpgradeLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => openUpgradeModal("batch")}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
isDisabled={selectedIds.size === 0}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
批量
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={handleAdd}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!wsConnected && (
|
||||
<Alert
|
||||
@@ -1367,7 +1461,7 @@ export default function NodePage() {
|
||||
isLoading={node.copyLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleCopyInstallCommand(node)}
|
||||
onPress={() => openInstallSelector(node)}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
@@ -1811,6 +1905,55 @@ export default function NodePage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
isOpen={installSelectorOpen}
|
||||
placement="center"
|
||||
size="md"
|
||||
onOpenChange={setInstallSelectorOpen}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">
|
||||
选择安装通道
|
||||
{installTargetNode ? ` - ${installTargetNode.name}` : ""}
|
||||
</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="版本通道"
|
||||
selectedKeys={[installChannel]}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as ReleaseChannel;
|
||||
|
||||
setInstallChannel(selected || "stable");
|
||||
}}
|
||||
>
|
||||
<SelectItem key="stable" textValue="stable">
|
||||
正式版(纯数字版本,如 2.1.4)
|
||||
</SelectItem>
|
||||
<SelectItem key="dev" textValue="dev">
|
||||
测试版(含 alpha / beta / rc)
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleConfirmInstallCommand}>
|
||||
生成命令
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 安装命令模态框 */}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
@@ -1891,9 +2034,28 @@ export default function NodePage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="版本通道"
|
||||
selectedKeys={[releaseChannel]}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected =
|
||||
(Array.from(keys)[0] as ReleaseChannel) || "stable";
|
||||
|
||||
setReleaseChannel(selected);
|
||||
setSelectedVersion("");
|
||||
void loadReleasesByChannel(selected);
|
||||
}}
|
||||
>
|
||||
<SelectItem key="stable" textValue="stable">
|
||||
正式版(纯数字版本,如 2.1.4)
|
||||
</SelectItem>
|
||||
<SelectItem key="dev" textValue="dev">
|
||||
测试版(含 alpha / beta / rc)
|
||||
</SelectItem>
|
||||
</Select>
|
||||
<Select
|
||||
label="选择版本"
|
||||
placeholder="留空则使用最新版本"
|
||||
placeholder="留空则使用当前通道最新版本"
|
||||
selectedKeys={selectedVersion ? [selectedVersion] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as string;
|
||||
@@ -1909,14 +2071,14 @@ export default function NodePage() {
|
||||
{r.publishedAt
|
||||
? new Date(r.publishedAt).toLocaleDateString()
|
||||
: ""}
|
||||
{r.prerelease && (
|
||||
{r.channel === "dev" && (
|
||||
<Chip
|
||||
className="ml-1"
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
预览
|
||||
测试
|
||||
</Chip>
|
||||
)}
|
||||
</span>
|
||||
@@ -1927,7 +2089,7 @@ export default function NodePage() {
|
||||
<p className="text-sm text-default-500">
|
||||
{selectedVersion
|
||||
? `将升级到版本 ${selectedVersion}`
|
||||
: "未选择版本,将自动使用最新稳定版"}
|
||||
: `未选择版本,将自动使用最新${releaseChannel === "stable" ? "正式" : "测试"}版`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1989,6 +2151,6 @@ export default function NodePage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -409,14 +409,15 @@ export default function PanelSharingPage() {
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardBody className="space-y-5 pt-4 md:pt-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<CardBody className="space-y-5">
|
||||
<div className="mt-4 flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Provider 共享
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
将本地节点分享给其他面板,统一管理 Token、端口范围和到期策略。
|
||||
将本地节点分享给其他面板,统一管理
|
||||
Token、端口范围和到期策略。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -429,7 +430,9 @@ export default function PanelSharingPage() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||
<div className="text-center py-12 text-default-500">
|
||||
加载中...
|
||||
</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
@@ -554,8 +557,8 @@ export default function PanelSharingPage() {
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardBody className="space-y-5 pt-4 md:pt-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<CardBody className="space-y-5">
|
||||
<div className="mt-4 flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Consumer 接入
|
||||
@@ -574,7 +577,9 @@ export default function PanelSharingPage() {
|
||||
</div>
|
||||
|
||||
{remoteUsageLoading ? (
|
||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||
<div className="text-center py-12 text-default-500">
|
||||
加载中...
|
||||
</div>
|
||||
) : remoteUsageNodes.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
@@ -669,7 +674,11 @@ export default function PanelSharingPage() {
|
||||
</Tabs>
|
||||
|
||||
{/* Create Share Modal */}
|
||||
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)}>
|
||||
<Modal
|
||||
isOpen={createShareOpen}
|
||||
scrollBehavior="inside"
|
||||
onClose={() => setCreateShareOpen(false)}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>创建分享</ModalHeader>
|
||||
<ModalBody>
|
||||
@@ -772,7 +781,11 @@ export default function PanelSharingPage() {
|
||||
</Modal>
|
||||
|
||||
{/* Edit Share Modal */}
|
||||
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
|
||||
<Modal
|
||||
isOpen={editShareOpen}
|
||||
scrollBehavior="inside"
|
||||
onClose={() => setEditShareOpen(false)}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>编辑分享</ModalHeader>
|
||||
<ModalBody>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
getTunnelTypeDisplay,
|
||||
validateTunnelForm,
|
||||
} from "@/pages/tunnel/form";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
import { extractApiErrorMessage } from "@/api/error-message";
|
||||
|
||||
@@ -111,6 +114,11 @@ export default function TunnelPage() {
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"tunnel-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
|
||||
// 模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -597,7 +605,19 @@ export default function TunnelPage() {
|
||||
const sortedTunnels = useMemo((): Tunnel[] => {
|
||||
if (!tunnels || tunnels.length === 0) return [];
|
||||
|
||||
const sortedByDb = [...tunnels].sort((a, b) => {
|
||||
let filteredTunnels = tunnels;
|
||||
|
||||
if (searchKeyword.trim()) {
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
|
||||
filteredTunnels = filteredTunnels.filter(
|
||||
(t) =>
|
||||
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}
|
||||
|
||||
const sortedByDb = [...filteredTunnels].sort((a, b) => {
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
|
||||
@@ -610,7 +630,7 @@ export default function TunnelPage() {
|
||||
tunnelOrder.length > 0 &&
|
||||
sortedByDb.every((t) => t.inx === undefined || t.inx === 0)
|
||||
) {
|
||||
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
|
||||
const tunnelMap = new Map(filteredTunnels.map((t) => [t.id, t] as const));
|
||||
const localSorted: Tunnel[] = [];
|
||||
|
||||
tunnelOrder.forEach((id) => {
|
||||
@@ -619,7 +639,7 @@ export default function TunnelPage() {
|
||||
if (tunnel) localSorted.push(tunnel);
|
||||
});
|
||||
|
||||
tunnels.forEach((tunnel) => {
|
||||
filteredTunnels.forEach((tunnel) => {
|
||||
if (!tunnelOrder.includes(tunnel.id)) {
|
||||
localSorted.push(tunnel);
|
||||
}
|
||||
@@ -629,7 +649,7 @@ export default function TunnelPage() {
|
||||
}
|
||||
|
||||
return sortedByDb;
|
||||
}, [tunnels, tunnelOrder]);
|
||||
}, [tunnels, tunnelOrder, searchKeyword]);
|
||||
|
||||
const sortableTunnelIds = useMemo(
|
||||
() => sortedTunnels.map((t) => t.id),
|
||||
@@ -652,11 +672,17 @@ export default function TunnelPage() {
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
const style: React.CSSProperties = {
|
||||
transform: transform
|
||||
? CSS.Transform.toString({
|
||||
...transform,
|
||||
x: Math.round(transform.x),
|
||||
y: Math.round(transform.y),
|
||||
})
|
||||
: undefined,
|
||||
transition: isDragging ? undefined : transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
willChange: "transform",
|
||||
willChange: isDragging ? "transform" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -671,68 +697,94 @@ export default function TunnelPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="default"
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
{selectMode ? "退出" : "批量"}
|
||||
</Button>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
<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-1 max-w-sm flex items-center gap-2">
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索隧道名称或IP"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectMode && selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
|
||||
<div className="flex min-w-max items-center gap-2">
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={batchLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={handleBatchRedeploy}
|
||||
>
|
||||
下发
|
||||
</Button>
|
||||
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
||||
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
isDisabled={selectedIds.size === 0}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={selectedIds.size === 0}
|
||||
isLoading={batchLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={handleBatchRedeploy}
|
||||
>
|
||||
下发
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
批量
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={handleAdd}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
{tunnels.length > 0 ? (
|
||||
@@ -1014,34 +1066,14 @@ export default function TunnelPage() {
|
||||
</DndContext>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无隧道配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何隧道配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无隧道配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何隧道配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
@@ -1127,11 +1159,6 @@ export default function TunnelPage() {
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
endContent={
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<span className="text-default-400 text-small">x</span>
|
||||
</div>
|
||||
}
|
||||
errorMessage={errors.trafficRatio}
|
||||
isInvalid={!!errors.trafficRatio}
|
||||
label="流量倍率"
|
||||
@@ -1384,6 +1411,7 @@ export default function TunnelPage() {
|
||||
.filter((id) => id !== -1)
|
||||
.map((id) => id.toString()),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
label="节点"
|
||||
placeholder="选择节点(可多选)"
|
||||
selectedKeys={groupNodes
|
||||
@@ -1599,6 +1627,7 @@ export default function TunnelPage() {
|
||||
id.toString(),
|
||||
),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
errorMessage={errors.outNodeId}
|
||||
isInvalid={!!errors.outNodeId}
|
||||
label="节点"
|
||||
@@ -2444,6 +2473,6 @@ export default function TunnelPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
+233
-201
@@ -2,6 +2,11 @@ import { useState, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
import {
|
||||
AnimatedPage,
|
||||
StaggerList,
|
||||
StaggerItem,
|
||||
} from "@/components/animated-page";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -57,10 +62,10 @@ import {
|
||||
SearchIcon,
|
||||
EditIcon,
|
||||
DeleteIcon,
|
||||
UserIcon,
|
||||
SettingsIcon,
|
||||
} from "@/components/icons";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
@@ -121,7 +126,11 @@ export default function UserPage() {
|
||||
// 状态管理
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"user-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [pagination, setPagination] = useState<PaginationType>({
|
||||
current: 1,
|
||||
size: 10,
|
||||
@@ -610,67 +619,89 @@ export default function UserPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<div className="flex items-center gap-3" />
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 max-w-md">
|
||||
<Input
|
||||
className="flex-1"
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
inputWrapper:
|
||||
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
|
||||
}}
|
||||
placeholder="搜索用户名"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<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">
|
||||
{!isSearchVisible ? (
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="搜索用户"
|
||||
className="min-h-10 w-10"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={handleSearch}
|
||||
aria-label="搜索"
|
||||
className="text-default-600"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setIsSearchVisible(true)}
|
||||
>
|
||||
<SearchIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button color="primary" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex w-full items-center gap-2 animate-appearance-in">
|
||||
<Input
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
inputWrapper:
|
||||
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
|
||||
}}
|
||||
placeholder="搜索用户名"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
setIsSearchVisible(false);
|
||||
setSearchKeyword("");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
{loading ? (
|
||||
<PageLoadingState message="正在加载..." />
|
||||
) : users.length === 0 ? (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<UserIcon className="w-8 h-8 text-default-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无用户数据
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何用户,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无用户数据
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何用户,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{users.map((user) => {
|
||||
const userStatus = getUserStatus(user);
|
||||
const expStatus = user.expTime
|
||||
@@ -686,169 +717,170 @@ export default function UserPage() {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||
{user.name || user.user}
|
||||
</h3>
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
@{user.user}
|
||||
</p>
|
||||
<StaggerItem key={user.id}>
|
||||
<Card className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||
{user.name || user.user}
|
||||
</h3>
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
@{user.user}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={userStatus.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{userStatus.text}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={userStatus.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{userStatus.text}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-2">
|
||||
{/* 流量信息 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">流量限制</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(user.flow, "gb")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">已使用</span>
|
||||
<span className="font-medium text-xs text-danger">
|
||||
{formatFlow(usedFlow)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`流量使用 ${flowPercent.toFixed(1)}%`}
|
||||
className="mt-1"
|
||||
color={
|
||||
flowPercent > 90
|
||||
? "danger"
|
||||
: flowPercent > 70
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
value={flowPercent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 其他信息 */}
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">转发数量</span>
|
||||
<span className="font-medium text-xs">{user.num}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">重置日期</span>
|
||||
<span className="text-xs">
|
||||
{user.flowResetTime === 0
|
||||
? "不重置"
|
||||
: `每月${user.flowResetTime}号`}
|
||||
</span>
|
||||
</div>
|
||||
{user.expTime && (
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-2">
|
||||
{/* 流量信息 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">过期时间</span>
|
||||
<div className="text-right">
|
||||
{expStatus && expStatus.color === "success" ? (
|
||||
<div className="text-xs">
|
||||
{formatDate(user.expTime)}
|
||||
</div>
|
||||
) : (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={expStatus?.color || "default"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expStatus?.text || "未知状态"}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-default-600">流量限制</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(user.flow, "gb")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">已使用</span>
|
||||
<span className="font-medium text-xs text-danger">
|
||||
{formatFlow(usedFlow)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`流量使用 ${flowPercent.toFixed(1)}%`}
|
||||
className="mt-1"
|
||||
color={
|
||||
flowPercent > 90
|
||||
? "danger"
|
||||
: flowPercent > 70
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
value={flowPercent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 mt-3">
|
||||
{/* 第一行:编辑和重置 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={<EditIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(user)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="warning"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleResetFlow(user)}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{/* 其他信息 */}
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">转发数量</span>
|
||||
<span className="font-medium text-xs">
|
||||
{user.num}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">重置日期</span>
|
||||
<span className="text-xs">
|
||||
{user.flowResetTime === 0
|
||||
? "不重置"
|
||||
: `每月${user.flowResetTime}号`}
|
||||
</span>
|
||||
</div>
|
||||
{user.expTime && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">过期时间</span>
|
||||
<div className="text-right">
|
||||
{expStatus && expStatus.color === "success" ? (
|
||||
<div className="text-xs">
|
||||
{formatDate(user.expTime)}
|
||||
</div>
|
||||
) : (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={expStatus?.color || "default"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expStatus?.text || "未知状态"}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:权限和删除 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
size="sm"
|
||||
startContent={<SettingsIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleManageTunnels(user)}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={<DeleteIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(user)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<div className="space-y-1.5 mt-3">
|
||||
{/* 第一行:编辑和重置 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={<EditIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(user)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="warning"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleResetFlow(user)}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 第二行:权限和删除 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
size="sm"
|
||||
startContent={<SettingsIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleManageTunnels(user)}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={<DeleteIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(user)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</StaggerItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</StaggerList>
|
||||
)}
|
||||
|
||||
{/* 用户表单模态框 */}
|
||||
@@ -1767,6 +1799,6 @@ export default function UserPage() {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
@@ -88,7 +87,6 @@ export function DatePicker({
|
||||
value,
|
||||
}: DatePickerProps) {
|
||||
const id = React.useId();
|
||||
const nativePickerRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const formattedValue = React.useMemo(() => formatDateValue(value), [value]);
|
||||
const [textValue, setTextValue] = React.useState(formattedValue);
|
||||
|
||||
@@ -160,30 +158,6 @@ export function DatePicker({
|
||||
setTextValue(formattedValue);
|
||||
};
|
||||
|
||||
const openNativePicker = () => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
const picker = nativePickerRef.current;
|
||||
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pickerWithShow = picker as HTMLInputElement & {
|
||||
showPicker?: () => void;
|
||||
};
|
||||
|
||||
if (typeof pickerWithShow.showPicker === "function") {
|
||||
pickerWithShow.showPicker();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
picker.focus();
|
||||
picker.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
description={description}
|
||||
@@ -193,71 +167,40 @@ export function DatePicker({
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
{shouldUseTextInput ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-invalid={isInvalid}
|
||||
className={cn("flex-1", className)}
|
||||
disabled={isDisabled}
|
||||
id={id}
|
||||
inputMode="numeric"
|
||||
placeholder="YYYY-MM-DD"
|
||||
required={isRequired}
|
||||
type="text"
|
||||
value={textValue}
|
||||
onBlur={commitTextInput}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
<Input
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(className)}
|
||||
disabled={isDisabled}
|
||||
id={id}
|
||||
inputMode={shouldUseTextInput ? "numeric" : undefined}
|
||||
placeholder={shouldUseTextInput ? "YYYY-MM-DD" : undefined}
|
||||
required={isRequired}
|
||||
type={shouldUseTextInput ? "text" : "date"}
|
||||
value={shouldUseTextInput ? textValue : formattedValue}
|
||||
onBlur={shouldUseTextInput ? commitTextInput : undefined}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
|
||||
setTextValue(nextValue);
|
||||
notifyTextDateChange(nextValue);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitTextInput();
|
||||
if (shouldUseTextInput) {
|
||||
setTextValue(nextValue);
|
||||
notifyTextDateChange(nextValue);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
notifyNativeDateChange(nextValue);
|
||||
}}
|
||||
onKeyDown={
|
||||
shouldUseTextInput
|
||||
? (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitTextInput();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
aria-label="打开日历选择器"
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-input bg-background text-default-600 shadow-sm transition-colors hover:bg-default-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isDisabled}
|
||||
type="button"
|
||||
onClick={openNativePicker}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
ref={nativePickerRef}
|
||||
aria-hidden="true"
|
||||
className="sr-only"
|
||||
disabled={isDisabled}
|
||||
tabIndex={-1}
|
||||
type="date"
|
||||
value={formattedValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
|
||||
setTextValue(nextValue);
|
||||
notifyNativeDateChange(nextValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(className)}
|
||||
disabled={isDisabled}
|
||||
id={id}
|
||||
required={isRequired}
|
||||
type="date"
|
||||
value={formattedValue}
|
||||
onChange={(event) => {
|
||||
notifyNativeDateChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ export function ModalFooter({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
"mt-4 flex flex-wrap justify-end gap-2",
|
||||
context?.classNames?.footer,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface SelectProps<T = unknown> extends FieldMetaProps {
|
||||
selectionMode?: SelectionMode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: string;
|
||||
dropdownPlacement?: "bottom" | "top";
|
||||
}
|
||||
|
||||
export interface SelectItemProps {
|
||||
@@ -163,6 +164,7 @@ export function Select<T>({
|
||||
selectedKeys,
|
||||
selectionMode = "single",
|
||||
size,
|
||||
dropdownPlacement = "bottom",
|
||||
}: SelectProps<T>) {
|
||||
const generatedId = React.useId();
|
||||
const options = React.useMemo(
|
||||
@@ -284,19 +286,22 @@ export function Select<T>({
|
||||
return null;
|
||||
}
|
||||
|
||||
const placementClasses =
|
||||
dropdownPlacement === "top" ? "bottom-full mb-1" : "top-full mt-1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-full z-50 mt-1 max-h-56 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md"
|
||||
id={`${generatedId}-listbox`}
|
||||
ref={listboxRef}
|
||||
className={cn(
|
||||
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
|
||||
placementClasses,
|
||||
)}
|
||||
id={`${generatedId}-listbox`}
|
||||
role="listbox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 py-1 text-default-500",
|
||||
textSizeClass(size),
|
||||
)}
|
||||
className={cn("px-2 py-1 text-default-500", textSizeClass(size))}
|
||||
>
|
||||
暂无可选项
|
||||
</div>
|
||||
@@ -325,9 +330,7 @@ export function Select<T>({
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-foreground",
|
||||
textSizeClass(size),
|
||||
optionDisabled
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
optionDisabled ? "cursor-not-allowed" : "cursor-pointer",
|
||||
)}
|
||||
disabled={optionDisabled}
|
||||
type="button"
|
||||
@@ -354,13 +357,7 @@ export function Select<T>({
|
||||
label={label}
|
||||
>
|
||||
{selectionMode === "multiple" ? (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div ref={containerRef} className={cn("relative w-full", className)}>
|
||||
<button
|
||||
aria-controls={`${generatedId}-listbox`}
|
||||
aria-expanded={isExpanded}
|
||||
|
||||
@@ -4,11 +4,15 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface TableClassNames {
|
||||
th?: string;
|
||||
td?: string;
|
||||
tr?: string;
|
||||
wrapper?: string;
|
||||
}
|
||||
|
||||
interface TableStyleContextValue {
|
||||
thClassName?: string;
|
||||
tdClassName?: string;
|
||||
trClassName?: string;
|
||||
}
|
||||
|
||||
const TableStyleContext = React.createContext<TableStyleContextValue>({});
|
||||
@@ -24,7 +28,13 @@ export function Table({
|
||||
...props
|
||||
}: TableProps) {
|
||||
return (
|
||||
<TableStyleContext.Provider value={{ thClassName: classNames?.th }}>
|
||||
<TableStyleContext.Provider
|
||||
value={{
|
||||
thClassName: classNames?.th,
|
||||
tdClassName: classNames?.td,
|
||||
trClassName: classNames?.tr,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}
|
||||
>
|
||||
@@ -62,18 +72,24 @@ interface TableBodyProps<T>
|
||||
loadingContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TableBody<T>({
|
||||
children,
|
||||
className,
|
||||
emptyContent,
|
||||
isLoading,
|
||||
items,
|
||||
loadingContent,
|
||||
...props
|
||||
}: TableBodyProps<T>) {
|
||||
export const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
TableBodyProps<any>
|
||||
>(function TableBody<T>(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
emptyContent,
|
||||
isLoading,
|
||||
items,
|
||||
loadingContent,
|
||||
...props
|
||||
}: TableBodyProps<T>,
|
||||
ref: React.Ref<HTMLTableSectionElement>,
|
||||
) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{loadingContent ?? "加载中..."}
|
||||
@@ -86,7 +102,7 @@ export function TableBody<T>({
|
||||
if (items && typeof children === "function") {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
@@ -97,7 +113,7 @@ export function TableBody<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
{items.map((item) => {
|
||||
const key =
|
||||
typeof item === "object" && item !== null && "id" in item
|
||||
@@ -115,7 +131,7 @@ export function TableBody<T>({
|
||||
|
||||
if (staticChildren.length === 0) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
@@ -126,11 +142,11 @@ export function TableBody<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
{typeof children === "function" ? null : children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function TableColumn({
|
||||
className,
|
||||
@@ -150,12 +166,29 @@ export function TableColumn({
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
export const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.ComponentProps<"tr">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { trClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<tr className={cn("border-b last:border-b-0", className)} {...props} />
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b last:border-b-0", trClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return <td className={cn("px-3 py-2 align-middle", className)} {...props} />;
|
||||
const { tdClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<td
|
||||
className={cn("px-3 py-2 align-middle", tdClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
@import "tw-animate-css";
|
||||
@import "./tailwind-theme.pcss";
|
||||
|
||||
/* GPU acceleration helper - prevents font blurriness during transforms */
|
||||
.gpu-accelerated {
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:root {
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
@@ -181,12 +189,12 @@ body {
|
||||
@keyframes captchaModalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_VERSION?: string;
|
||||
|
||||
@@ -2,9 +2,50 @@ import path from "path";
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "auto",
|
||||
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
|
||||
manifest: {
|
||||
name: "FLVX",
|
||||
short_name: "FLVX",
|
||||
description: "FLVX forwarding management panel",
|
||||
theme_color: "#2563eb",
|
||||
background_color: "#f6f7fb",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
icons: [
|
||||
{
|
||||
src: "pwa-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-maskable-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "/index.html",
|
||||
cleanupOutdatedCaches: true,
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
],
|
||||
base: "/",
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user