mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 02:36:37 +08:00
fix: stabilize federation forward runtime cleanup and frontend version UX (#207)
## Summary - fix federation forward runtime cleanup so valid forward listeners are not treated as orphan during unbound binding windows - add backend regression coverage for federation forward/runtime cleanup and expiry handling paths - wire frontend version update footer/flows and related UI updates across admin pages ## Verification - go test ./... - make build
This commit is contained in:
@@ -157,6 +157,12 @@ func (h *Handler) processPeerShareFlowFromForward(forwardID int64, serviceName s
|
|||||||
if h == nil || h.repo == nil || forwardID <= 0 {
|
if h == nil || h.repo == nil || forwardID <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delta := item.D + item.U
|
||||||
|
if delta <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
forward, err := h.getForwardRecord(forwardID)
|
forward, err := h.getForwardRecord(forwardID)
|
||||||
if err != nil || forward == nil {
|
if err != nil || forward == nil {
|
||||||
// Forward not found in local database - might be a federation port-forward
|
// Forward not found in local database - might be a federation port-forward
|
||||||
@@ -164,26 +170,22 @@ func (h *Handler) processPeerShareFlowFromForward(forwardID int64, serviceName s
|
|||||||
h.processPeerShareFlowByServiceName(serviceName, item)
|
h.processPeerShareFlowByServiceName(serviceName, item)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
|
||||||
if err != nil || tunnel == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
|
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.processPeerShareFlowByServiceName(serviceName, item)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
|
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
h.processPeerShareFlowByServiceName(serviceName, item)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
delta := item.D + item.U
|
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
|
||||||
if delta <= 0 {
|
h.processPeerShareFlowByServiceName(serviceName, item)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = h.repo.AddPeerShareCurrentFlow(shareID, delta)
|
|
||||||
|
|
||||||
share, err := h.repo.GetPeerShare(shareID)
|
share, err := h.repo.GetPeerShare(shareID)
|
||||||
if err != nil || share == nil {
|
if err != nil || share == nil {
|
||||||
return
|
return
|
||||||
@@ -404,6 +406,11 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
|
||||||
|
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
|
||||||
|
if err != nil {
|
||||||
|
hasUnboundForwardPeerRuntime = false
|
||||||
|
}
|
||||||
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
|
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
|
||||||
for _, serviceName := range runtimeServiceNames {
|
for _, serviceName := range runtimeServiceNames {
|
||||||
serviceName = strings.TrimSpace(serviceName)
|
serviceName = strings.TrimSpace(serviceName)
|
||||||
@@ -432,6 +439,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
|||||||
parts := strings.Split(name, "_")
|
parts := strings.Split(name, "_")
|
||||||
if len(parts) >= 3 {
|
if len(parts) >= 3 {
|
||||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
|
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
|
||||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
|
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
|
||||||
continue
|
continue
|
||||||
@@ -451,6 +461,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
|
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,66 @@ func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessFlowItemFallsBackToServiceNameWhenForwardIDCollidesAcrossPanels(t *testing.T) {
|
||||||
|
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-collision.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||||
|
Name: "collision-share",
|
||||||
|
NodeID: 1,
|
||||||
|
Token: "collision-token",
|
||||||
|
MaxBandwidth: 0,
|
||||||
|
CurrentFlow: 0,
|
||||||
|
PortRangeStart: 31400,
|
||||||
|
PortRangeEnd: 31410,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create peer share: %v", err)
|
||||||
|
}
|
||||||
|
share, err := r.GetPeerShareByToken("collision-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, "collision-r1", "collision-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31401, "", 1, 1, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(2, 'local-tunnel-with-colliding-forward-id', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||||
|
`, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert local 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, 1, 'local-user', 'local-f20', 2, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||||
|
`, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert local forward: %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) {
|
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
|
||||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
|
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -299,3 +359,48 @@ func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
|
|||||||
|
|
||||||
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
|
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanOrphanedServicesSkipsForwardPatternWhenNodeHasActivePeerShareForwardRuntime(t *testing.T) {
|
||||||
|
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-forward-runtime-empty-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: "cleanup-forward-runtime-empty-service",
|
||||||
|
NodeID: 1,
|
||||||
|
Token: "cleanup-forward-runtime-empty-service-token",
|
||||||
|
MaxBandwidth: 0,
|
||||||
|
CurrentFlow: 0,
|
||||||
|
PortRangeStart: 31420,
|
||||||
|
PortRangeEnd: 31430,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create peer share: %v", err)
|
||||||
|
}
|
||||||
|
share, err := r.GetPeerShareByToken("cleanup-forward-runtime-empty-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, "cleanup-forward-empty-r1", "cleanup-forward-empty-rk1", "", "forward", "", "", "tcp", "fifo", 31421, "", 0, 1, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert peer_share_runtime with empty service name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
t.Fatalf("cleanOrphanedServices should skip forward-pattern services when active peer-share forward runtime exists; got panic: %v", rec)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
|||||||
t.Fatalf("insert expired user: %v", err)
|
t.Fatalf("insert expired user: %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(3, 'non_expiring_user', 'x', 1, 0, 100, 1000, 2000, 15, 1, ?, ?, 1)
|
||||||
|
`, nowMs, nowMs).Error; err != nil {
|
||||||
|
t.Fatalf("insert non-expiring user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.DB().Exec(`
|
if err := r.DB().Exec(`
|
||||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||||
@@ -83,6 +90,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
|||||||
t.Fatalf("insert expired user_tunnel: %v", err)
|
t.Fatalf("insert expired user_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(11, 3, 1, NULL, 1, 1, 300, 400, 15, 0, 1)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("insert non-expiring user_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.DB().Exec(`
|
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)
|
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, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||||
@@ -90,6 +104,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
|||||||
t.Fatalf("insert forward: %v", err)
|
t.Fatalf("insert forward: %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(21, 3, 'non_expiring_user', 'f2', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 1)
|
||||||
|
`, nowMs, nowMs).Error; err != nil {
|
||||||
|
t.Fatalf("insert non-expiring forward: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
h.runResetAndExpiryJob(now)
|
h.runResetAndExpiryJob(now)
|
||||||
|
|
||||||
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
|
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
|
||||||
@@ -106,4 +127,19 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
|||||||
if forwardStatus != 0 {
|
if forwardStatus != 0 {
|
||||||
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
|
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonExpUserStatus := mustQueryInt(t, r, `SELECT status FROM user WHERE id = 3`)
|
||||||
|
if nonExpUserStatus != 1 {
|
||||||
|
t.Fatalf("expected non-expiring user to remain enabled, got status=%d", nonExpUserStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonExpTunnelStatus := mustQueryInt(t, r, `SELECT status FROM user_tunnel WHERE id = 11`)
|
||||||
|
if nonExpTunnelStatus != 1 {
|
||||||
|
t.Fatalf("expected non-expiring user_tunnel to remain enabled, got status=%d", nonExpTunnelStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonExpForwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 21`)
|
||||||
|
if nonExpForwardStatus != 1 {
|
||||||
|
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1304,6 +1304,20 @@ func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID
|
|||||||
return names, nil
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (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 status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
|
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
@@ -2327,7 +2341,7 @@ func (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {
|
|||||||
}
|
}
|
||||||
var ids []int64
|
var ids []int64
|
||||||
err := r.db.Model(&model.User{}).
|
err := r.db.Model(&model.User{}).
|
||||||
Where("role_id != 0 AND status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).
|
Where("role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).
|
||||||
Pluck("id", &ids).Error
|
Pluck("id", &ids).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2347,7 +2361,7 @@ func (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredU
|
|||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
var uts []model.UserTunnel
|
var uts []model.UserTunnel
|
||||||
err := r.db.Where("status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).Find(&uts).Error
|
err := r.db.Where("status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).Find(&uts).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export function SearchBar({
|
|||||||
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "bg-default-100",
|
base: "bg-default-100",
|
||||||
input:
|
input:
|
||||||
|
|||||||
@@ -38,13 +38,19 @@ function Alert({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
|
function AlertTitle({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"h5">) {
|
||||||
return (
|
return (
|
||||||
<h5
|
<h5
|
||||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
data-slot="alert-title"
|
data-slot="alert-title"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</h5>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
function CardTitle({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"h3">) {
|
||||||
return (
|
return (
|
||||||
<h3
|
<h3
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -34,7 +38,9 @@ function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
|||||||
)}
|
)}
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { siteConfig } from "@/config/site";
|
||||||
|
import {
|
||||||
|
UPDATE_CHANNEL_CHANGED_EVENT,
|
||||||
|
type UpdateReleaseChannel,
|
||||||
|
getLatestVersionByChannel,
|
||||||
|
getUpdateReleaseChannel,
|
||||||
|
hasVersionUpdate,
|
||||||
|
} from "@/utils/version-update";
|
||||||
|
|
||||||
|
const FALLBACK_GITHUB_REPO = "https://github.com/Sagit-chu/flux-panel";
|
||||||
|
|
||||||
|
interface VersionFooterProps {
|
||||||
|
version: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
versionClassName?: string;
|
||||||
|
poweredClassName?: string;
|
||||||
|
updateBadgeClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionFooter({
|
||||||
|
version,
|
||||||
|
containerClassName,
|
||||||
|
versionClassName,
|
||||||
|
poweredClassName,
|
||||||
|
updateBadgeClassName,
|
||||||
|
}: VersionFooterProps) {
|
||||||
|
const [channel, setChannel] = useState<UpdateReleaseChannel>(
|
||||||
|
getUpdateReleaseChannel(),
|
||||||
|
);
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
|
const [latestUpdateVersion, setLatestUpdateVersion] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChannelChange = () => {
|
||||||
|
setChannel(getUpdateReleaseChannel());
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(UPDATE_CHANNEL_CHANGED_EVENT, handleChannelChange);
|
||||||
|
window.addEventListener("storage", handleChannelChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
UPDATE_CHANNEL_CHANGED_EVENT,
|
||||||
|
handleChannelChange,
|
||||||
|
);
|
||||||
|
window.removeEventListener("storage", handleChannelChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const checkUpdate = async () => {
|
||||||
|
const latestVersion = await getLatestVersionByChannel(
|
||||||
|
channel,
|
||||||
|
siteConfig.github_repo || FALLBACK_GITHUB_REPO,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latestVersion) {
|
||||||
|
setUpdateAvailable(false);
|
||||||
|
setLatestUpdateVersion(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUpdate = hasVersionUpdate(version, latestVersion);
|
||||||
|
|
||||||
|
setUpdateAvailable(hasUpdate);
|
||||||
|
setLatestUpdateVersion(hasUpdate ? latestVersion : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkUpdate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [channel, version]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClassName}>
|
||||||
|
<p className={versionClassName}>
|
||||||
|
v{version}
|
||||||
|
{updateAvailable && latestUpdateVersion && (
|
||||||
|
<span className={updateBadgeClassName} role="status">
|
||||||
|
{latestUpdateVersion}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className={poweredClassName}>
|
||||||
|
Powered by{" "}
|
||||||
|
<a
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
href={siteConfig.github_repo}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
FLVX
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/shadcn-bridge/heroui/modal";
|
} from "@/shadcn-bridge/heroui/modal";
|
||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
import { Logo } from "@/components/icons";
|
import { Logo } from "@/components/icons";
|
||||||
|
import { VersionFooter } from "@/components/version-footer";
|
||||||
import { updatePassword } from "@/api";
|
import { updatePassword } from "@/api";
|
||||||
import { safeLogout } from "@/utils/logout";
|
import { safeLogout } from "@/utils/logout";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
@@ -337,9 +338,6 @@ export default function AdminLayout({
|
|||||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
||||||
{siteConfig.name}
|
{siteConfig.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">
|
|
||||||
v{siteConfig.version}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,17 +403,12 @@ export default function AdminLayout({
|
|||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}
|
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">
|
<VersionFooter
|
||||||
Powered by{" "}
|
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||||
<a
|
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
|
||||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
version={siteConfig.version}
|
||||||
href={siteConfig.github_repo}
|
versionClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||||
rel="noopener noreferrer"
|
/>
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
FLVX
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 桌面端折叠按钮 */}
|
{/* 桌面端折叠按钮 */}
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ import {
|
|||||||
clearConfigCache,
|
clearConfigCache,
|
||||||
updateSiteConfig,
|
updateSiteConfig,
|
||||||
} from "@/config/site";
|
} from "@/config/site";
|
||||||
|
import {
|
||||||
|
type UpdateReleaseChannel,
|
||||||
|
getUpdateReleaseChannel,
|
||||||
|
setUpdateReleaseChannel,
|
||||||
|
} from "@/utils/version-update";
|
||||||
|
|
||||||
// 简单的保存图标组件
|
// 简单的保存图标组件
|
||||||
const SaveIcon = ({ className }: { className?: string }) => (
|
const SaveIcon = ({ className }: { className?: string }) => (
|
||||||
@@ -182,6 +187,9 @@ export default function ConfigPage() {
|
|||||||
});
|
});
|
||||||
const [announcementLoading, setAnnouncementLoading] = useState(true);
|
const [announcementLoading, setAnnouncementLoading] = useState(true);
|
||||||
const [announcementSaving, setAnnouncementSaving] = useState(false);
|
const [announcementSaving, setAnnouncementSaving] = useState(false);
|
||||||
|
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||||
|
getUpdateReleaseChannel(),
|
||||||
|
);
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -267,6 +275,14 @@ export default function ConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||||
|
setUpdateChannel(channel);
|
||||||
|
setUpdateReleaseChannel(channel);
|
||||||
|
toast.success(
|
||||||
|
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfigChange = (key: string, value: string) => {
|
const handleConfigChange = (key: string, value: string) => {
|
||||||
const newConfigs = { ...configs, [key]: value };
|
const newConfigs = { ...configs, [key]: value };
|
||||||
|
|
||||||
@@ -643,6 +659,42 @@ export default function ConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<Divider className="my-2" />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
更新通道
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc
|
||||||
|
的版本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
selectedKeys={[updateChannel]}
|
||||||
|
size="md"
|
||||||
|
variant="bordered"
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected =
|
||||||
|
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
|
||||||
|
|
||||||
|
handleUpdateChannelChange(selected);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="stable" description="仅纯数字版本,如 2.1.4">
|
||||||
|
稳定版
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
key="dev"
|
||||||
|
description="仅 alpha / beta / rc 关键字版本"
|
||||||
|
>
|
||||||
|
开发版
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -3199,7 +3199,7 @@ export default function ForwardPage() {
|
|||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className="flex flex-col gap-4 py-2">
|
<div className="flex flex-col gap-4 py-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium">按用户筛选</label>
|
<p className="text-sm font-medium">按用户筛选</p>
|
||||||
<Select
|
<Select
|
||||||
aria-label="筛选用户"
|
aria-label="筛选用户"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -3220,7 +3220,7 @@ export default function ForwardPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium">按隧道筛选</label>
|
<p className="text-sm font-medium">按隧道筛选</p>
|
||||||
<Select
|
<Select
|
||||||
aria-label="筛选隧道"
|
aria-label="筛选隧道"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Input } from "@/shadcn-bridge/heroui/input";
|
|||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
import { title } from "@/components/primitives";
|
import { title } from "@/components/primitives";
|
||||||
|
import { VersionFooter } from "@/components/version-footer";
|
||||||
import DefaultLayout from "@/layouts/default";
|
import DefaultLayout from "@/layouts/default";
|
||||||
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
|
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
|
||||||
import { writeLoginSession } from "@/utils/session";
|
import { writeLoginSession } from "@/utils/session";
|
||||||
@@ -213,22 +214,13 @@ export default function IndexPage() {
|
|||||||
|
|
||||||
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
||||||
|
|
||||||
<div className="fixed inset-x-0 bottom-4 text-center py-4">
|
<VersionFooter
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
containerClassName="fixed inset-x-0 bottom-4 text-center py-4"
|
||||||
Powered by{" "}
|
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||||
<a
|
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
|
||||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
version={isWebView ? siteConfig.app_version : siteConfig.version}
|
||||||
href={siteConfig.github_repo}
|
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
|
||||||
rel="noopener noreferrer"
|
/>
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
FLVX
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
|
||||||
v{isWebView ? siteConfig.app_version : siteConfig.version}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 验证码弹层 */}
|
{/* 验证码弹层 */}
|
||||||
{showCaptcha && siteKey && (
|
{showCaptcha && siteKey && (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
import { isWebViewFunc } from "@/utils/panel";
|
import { isWebViewFunc } from "@/utils/panel";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
|
import { VersionFooter } from "@/components/version-footer";
|
||||||
import { updatePassword } from "@/api";
|
import { updatePassword } from "@/api";
|
||||||
import { safeLogout } from "@/utils/logout";
|
import { safeLogout } from "@/utils/logout";
|
||||||
import { getAdminFlag, getSessionName } from "@/utils/session";
|
import { getAdminFlag, getSessionName } from "@/utils/session";
|
||||||
@@ -312,22 +313,15 @@ export default function ProfilePage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="fixed inset-x-0 bottom-20 text-center py-4">
|
<VersionFooter
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
containerClassName="fixed inset-x-0 bottom-20 text-center py-4"
|
||||||
Powered by{" "}
|
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||||
<a
|
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
|
||||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
version={
|
||||||
href={siteConfig.github_repo}
|
isWebViewFunc() ? siteConfig.app_version : siteConfig.version
|
||||||
rel="noopener noreferrer"
|
}
|
||||||
target="_blank"
|
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
|
||||||
>
|
/>
|
||||||
FLVX
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
|
||||||
v{isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 修改密码弹窗 */}
|
{/* 修改密码弹窗 */}
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import toast from "react-hot-toast";
|
|||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||||
|
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||||
import { reinitializeBaseURL } from "@/api/network";
|
import { reinitializeBaseURL } from "@/api/network";
|
||||||
|
import {
|
||||||
|
type UpdateReleaseChannel,
|
||||||
|
getUpdateReleaseChannel,
|
||||||
|
setUpdateReleaseChannel,
|
||||||
|
} from "@/utils/version-update";
|
||||||
import {
|
import {
|
||||||
getPanelAddresses,
|
getPanelAddresses,
|
||||||
savePanelAddress,
|
savePanelAddress,
|
||||||
@@ -25,6 +31,9 @@ export const SettingsPage = () => {
|
|||||||
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [newAddress, setNewAddress] = useState("");
|
const [newAddress, setNewAddress] = useState("");
|
||||||
|
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||||
|
getUpdateReleaseChannel(),
|
||||||
|
);
|
||||||
|
|
||||||
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
|
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
|
||||||
setPanelAddresses(newAddress);
|
setPanelAddresses(newAddress);
|
||||||
@@ -79,6 +88,14 @@ export const SettingsPage = () => {
|
|||||||
loadPanelAddresses();
|
loadPanelAddresses();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||||
|
setUpdateChannel(channel);
|
||||||
|
setUpdateReleaseChannel(channel);
|
||||||
|
toast.success(
|
||||||
|
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-black">
|
<div className="min-h-screen bg-gray-50 dark:bg-black">
|
||||||
{/* 顶部导航 */}
|
{/* 顶部导航 */}
|
||||||
@@ -117,6 +134,36 @@ export const SettingsPage = () => {
|
|||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Card className="border border-gray-200 dark:border-gray-700">
|
||||||
|
<CardBody className="p-6">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||||
|
更新设置
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select
|
||||||
|
label="更新通道"
|
||||||
|
selectedKeys={[updateChannel]}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected =
|
||||||
|
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
|
||||||
|
|
||||||
|
handleUpdateChannelChange(selected);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="stable" textValue="stable">
|
||||||
|
稳定版(纯数字版本,如 2.1.4)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="dev" textValue="dev">
|
||||||
|
开发版(含 alpha / beta / rc)
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
版本提示会根据该通道检查最新版本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 添加新地址 */}
|
{/* 添加新地址 */}
|
||||||
<Card className="border border-gray-200 dark:border-gray-700">
|
<Card className="border border-gray-200 dark:border-gray-700">
|
||||||
<CardBody className="p-6">
|
<CardBody className="p-6">
|
||||||
|
|||||||
@@ -638,7 +638,6 @@ export default function UserPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex w-full items-center gap-2 animate-appearance-in">
|
<div className="flex w-full items-center gap-2 animate-appearance-in">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "bg-default-100",
|
base: "bg-default-100",
|
||||||
input: "bg-transparent",
|
input: "bg-transparent",
|
||||||
|
|||||||
@@ -19,11 +19,18 @@ function mapColor(color: LinkColor) {
|
|||||||
return "text-default-600 hover:text-default-700";
|
return "text-default-600 hover:text-default-700";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Link({ className, color = "default", ...props }: LinkProps) {
|
export function Link({
|
||||||
|
className,
|
||||||
|
color = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: LinkProps) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className={cn("transition-colors", mapColor(color), className)}
|
className={cn("transition-colors", mapColor(color), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
export type UpdateReleaseChannel = "stable" | "dev";
|
||||||
|
|
||||||
|
export const UPDATE_CHANNEL_STORAGE_KEY = "update-release-channel";
|
||||||
|
export const UPDATE_CHANNEL_CHANGED_EVENT = "updateReleaseChannelChanged";
|
||||||
|
|
||||||
|
const CHANNEL_STABLE: UpdateReleaseChannel = "stable";
|
||||||
|
const CHANNEL_DEV: UpdateReleaseChannel = "dev";
|
||||||
|
|
||||||
|
const stableVersionPattern = /^\d+(?:\.\d+)+$/;
|
||||||
|
const testKeywordPattern = /(alpha|beta|rc)/i;
|
||||||
|
|
||||||
|
const VERSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
type ReleaseItem = {
|
||||||
|
tag_name?: string;
|
||||||
|
draft?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LatestVersionCacheEntry = {
|
||||||
|
value: string | null;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const latestVersionCache: Record<
|
||||||
|
UpdateReleaseChannel,
|
||||||
|
LatestVersionCacheEntry
|
||||||
|
> = {
|
||||||
|
stable: { value: null, expiresAt: 0 },
|
||||||
|
dev: { value: null, expiresAt: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeChannel = (
|
||||||
|
value: string | null | undefined,
|
||||||
|
): UpdateReleaseChannel => {
|
||||||
|
return value === CHANNEL_DEV ? CHANNEL_DEV : CHANNEL_STABLE;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUpdateReleaseChannel = (): UpdateReleaseChannel => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return CHANNEL_STABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeChannel(localStorage.getItem(UPDATE_CHANNEL_STORAGE_KEY));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setUpdateReleaseChannel = (
|
||||||
|
channel: UpdateReleaseChannel,
|
||||||
|
): void => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(UPDATE_CHANNEL_STORAGE_KEY, normalizeChannel(channel));
|
||||||
|
window.dispatchEvent(new Event(UPDATE_CHANNEL_CHANGED_EVENT));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTag = (tag: string): string => {
|
||||||
|
return tag.trim().replace(/^v/i, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReleaseTagChannel = UpdateReleaseChannel | null;
|
||||||
|
|
||||||
|
const releaseChannelFromTag = (tag: string): ReleaseTagChannel => {
|
||||||
|
const normalizedTag = normalizeTag(tag).toLowerCase();
|
||||||
|
|
||||||
|
if (!normalizedTag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stableVersionPattern.test(normalizedTag)) {
|
||||||
|
return CHANNEL_STABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testKeywordPattern.test(normalizedTag)) {
|
||||||
|
return CHANNEL_DEV;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VersionParts = {
|
||||||
|
numbers: number[];
|
||||||
|
stageRank: number;
|
||||||
|
stageNumber: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseVersionParts = (version: string): VersionParts => {
|
||||||
|
const normalized = normalizeTag(version).toLowerCase();
|
||||||
|
const numberMatches = normalized.match(/\d+/g) || [];
|
||||||
|
const numbers = numberMatches.map((item) => Number.parseInt(item, 10));
|
||||||
|
|
||||||
|
let stageRank = 0;
|
||||||
|
|
||||||
|
if (normalized.includes("rc")) {
|
||||||
|
stageRank = 3;
|
||||||
|
} else if (normalized.includes("beta")) {
|
||||||
|
stageRank = 2;
|
||||||
|
} else if (normalized.includes("alpha")) {
|
||||||
|
stageRank = 1;
|
||||||
|
} else if (stableVersionPattern.test(normalized)) {
|
||||||
|
stageRank = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageNumberMatch = normalized.match(/(?:alpha|beta|rc)[.-]?(\d+)/);
|
||||||
|
const stageNumber = stageNumberMatch
|
||||||
|
? Number.parseInt(stageNumberMatch[1], 10)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
numbers,
|
||||||
|
stageRank,
|
||||||
|
stageNumber,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compareVersions = (left: string, right: string): number => {
|
||||||
|
const a = parseVersionParts(left);
|
||||||
|
const b = parseVersionParts(right);
|
||||||
|
const maxLength = Math.max(a.numbers.length, b.numbers.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLength; i += 1) {
|
||||||
|
const aValue = a.numbers[i] || 0;
|
||||||
|
const bValue = b.numbers[i] || 0;
|
||||||
|
|
||||||
|
if (aValue !== bValue) {
|
||||||
|
return aValue - bValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.stageRank !== b.stageRank) {
|
||||||
|
return a.stageRank - b.stageRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.stageNumber !== b.stageNumber) {
|
||||||
|
return a.stageNumber - b.stageNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const repoPathFromUrl = (repoUrl: string): string | null => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(repoUrl);
|
||||||
|
const segments = parsed.pathname
|
||||||
|
.replace(/\.git$/i, "")
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (segments.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${segments[0]}/${segments[1]}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLatestVersionByChannel = async (
|
||||||
|
channel: UpdateReleaseChannel,
|
||||||
|
repoUrl: string,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
const normalizedChannel = normalizeChannel(channel);
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = latestVersionCache[normalizedChannel];
|
||||||
|
|
||||||
|
if (cached.value && cached.expiresAt > now) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoPath = repoPathFromUrl(repoUrl);
|
||||||
|
|
||||||
|
if (!repoPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.github.com/repos/${repoPath}/releases?per_page=50`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases = (await response.json()) as ReleaseItem[];
|
||||||
|
const candidateTags = releases
|
||||||
|
.filter((release) => !release.draft && typeof release.tag_name === "string")
|
||||||
|
.map((release) => (release.tag_name || "").trim())
|
||||||
|
.filter((tag) => releaseChannelFromTag(tag) === normalizedChannel);
|
||||||
|
|
||||||
|
if (candidateTags.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = candidateTags.sort((a, b) => compareVersions(b, a))[0];
|
||||||
|
|
||||||
|
latestVersionCache[normalizedChannel] = {
|
||||||
|
value: latest,
|
||||||
|
expiresAt: now + VERSION_CACHE_TTL_MS,
|
||||||
|
};
|
||||||
|
|
||||||
|
return latest;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasVersionUpdate = (
|
||||||
|
currentVersion: string,
|
||||||
|
latestVersion: string,
|
||||||
|
): boolean => {
|
||||||
|
return (
|
||||||
|
compareVersions(normalizeTag(currentVersion), normalizeTag(latestVersion)) <
|
||||||
|
0
|
||||||
|
);
|
||||||
|
};
|
||||||
Vendored
+8
@@ -9,3 +9,11 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "virtual:pwa-register" {
|
||||||
|
export function registerSW(options?: {
|
||||||
|
immediate?: boolean;
|
||||||
|
onNeedRefresh?: () => void;
|
||||||
|
onOfflineReady?: () => void;
|
||||||
|
}): (reloadPage?: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user