diff --git a/go-backend/internal/http/handler/jobs.go b/go-backend/internal/http/handler/jobs.go index d7c697e..2096b39 100644 --- a/go-backend/internal/http/handler/jobs.go +++ b/go-backend/internal/http/handler/jobs.go @@ -18,11 +18,12 @@ func (h *Handler) StartBackgroundJobs() { ctx, cancel := context.WithCancel(context.Background()) h.jobsCancel = cancel h.jobsStarted = true - h.jobsWG.Add(2) + h.jobsWG.Add(3) h.jobsMu.Unlock() go h.runHourlyStatsLoop(ctx) go h.runDailyMaintenanceLoop(ctx) + go h.runNodeRenewalCycleLoop(ctx) } func (h *Handler) StopBackgroundJobs() { @@ -176,3 +177,39 @@ func (h *Handler) disableExpiredUserTunnels(nowMs int64) { _ = h.repo.DisableUserTunnel(item.ID) } } + +func (h *Handler) runNodeRenewalCycleLoop(ctx context.Context) { + defer h.jobsWG.Done() + + for { + wait := durationUntilNextNodeRenewalCycle(time.Now()) + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return + case <-timer.C: + h.runNodeRenewalCycleJob(time.Now()) + } + } +} + +func durationUntilNextNodeRenewalCycle(now time.Time) time.Duration { + next := now.Truncate(6 * time.Hour).Add(6 * time.Hour) + return next.Sub(now) +} + +func (h *Handler) runNodeRenewalCycleJob(now time.Time) { + if h == nil || h.repo == nil { + return + } + + advanced, err := h.repo.AdvanceNodeRenewalCycles(now.UnixMilli()) + if err != nil { + return + } + + _ = advanced +} diff --git a/go-backend/internal/http/handler/jobs_renewal_test.go b/go-backend/internal/http/handler/jobs_renewal_test.go new file mode 100644 index 0000000..5643681 --- /dev/null +++ b/go-backend/internal/http/handler/jobs_renewal_test.go @@ -0,0 +1,55 @@ +package handler + +import ( + "database/sql" + "testing" + "time" + + "go-backend/internal/store/repo" +) + +func TestRunNodeRenewalCycleJob_AdvancesOverdueAnchorTimes(t *testing.T) { + dbPath := t.TempDir() + "/renewal-test.db" + r, err := repo.Open(dbPath) + if err != nil { + t.Fatalf("open repo: %v", err) + } + t.Cleanup(func() { + _ = r.Close() + }) + + now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) + nowMs := now.UnixMilli() + + nodeID := int64(101) + err = r.DB().Exec(` + INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, nodeID, "no-cycle-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "", nil).Error + if err != nil { + t.Fatalf("insert test node: %v", err) + } + + quarterNodeID := int64(102) + err = r.DB().Exec(` + INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, quarterNodeID, "quarter-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "quarter", now.AddDate(0, -4, 0).UnixMilli()).Error + if err != nil { + t.Fatalf("insert test node: %v", err) + } + + h := &Handler{repo: r} + h.runNodeRenewalCycleJob(now) + + var anchor sql.NullInt64 + err = r.DB().Raw(`SELECT expiry_time FROM node WHERE id = ?`, quarterNodeID).Row().Scan(&anchor) + if err != nil { + t.Fatalf("query expiry_time: %v", err) + } + + expectedAnchor := now.AddDate(0, 2, 0).UnixMilli() + if !anchor.Valid || anchor.Int64 != expectedAnchor { + t.Fatalf("expected anchor %d (2026-05-08), got %d", expectedAnchor, anchor.Int64) + } +} diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 1a6eeb7..6ea763e 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -264,6 +264,7 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) { nullableText(strings.TrimSpace(asString(req["remark"]))), nullableText(strings.TrimSpace(asString(req["tags"]))), nullableUnixMilli(asInt64(req["expiryTime"], 0)), + nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))), asInt(req["http"], 0), asInt(req["tls"], 0), asInt(req["socks"], 0), @@ -332,6 +333,7 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) { nullableText(strings.TrimSpace(asString(req["remark"]))), nullableText(strings.TrimSpace(asString(req["tags"]))), nullableUnixMilli(asInt64(req["expiryTime"], 0)), + nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))), newHTTP, newTLS, newSocks, @@ -3547,6 +3549,15 @@ func nullableUnixMilli(v int64) interface{} { return v } +func normalizeNodeRenewalCycle(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "month", "quarter", "year": + return strings.ToLower(strings.TrimSpace(v)) + default: + return "" + } +} + func nullableInt(v *int64) interface{} { if v == nil { return nil diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index d69a6e1..ede2d01 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -63,6 +63,7 @@ type Node struct { Remark sql.NullString `gorm:"column:remark;type:text"` Tags sql.NullString `gorm:"column:tags;type:text"` ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"` + RenewalCycle sql.NullString `gorm:"column:renewal_cycle;type:varchar(20)"` Secret string `gorm:"type:varchar(100);not null"` ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"` ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"` @@ -342,6 +343,7 @@ type NodeBackup struct { Remark string `json:"remark,omitempty"` Tags string `json:"tags,omitempty"` ExpiryTime int64 `json:"expiryTime,omitempty"` + RenewalCycle string `json:"renewalCycle,omitempty"` Secret string `json:"secret"` ServerIP string `json:"serverIp"` ServerIPv4 string `json:"serverIpV4,omitempty"` diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index e0b9949..d5168cd 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -260,7 +260,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error { m := db.Migrator() if m.HasTable(&model.Node{}) { - for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} { + for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "Tags", "ExpiryTime", "RenewalCycle"} { if m.HasColumn(&model.Node{}, field) { continue } @@ -634,10 +634,11 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) { for _, n := range nodes { items = append(items, map[string]interface{}{ "id": n.ID, "inx": n.Inx, "name": n.Name, - "remark": nullableString(n.Remark), - "tags": nullableString(n.Tags), - "expiryTime": nullableInt64(n.ExpiryTime), - "ip": n.ServerIP, "serverIp": n.ServerIP, + "remark": nullableString(n.Remark), + "tags": nullableString(n.Tags), + "expiryTime": nullableInt64(n.ExpiryTime), + "renewalCycle": nullableString(n.RenewalCycle), + "ip": n.ServerIP, "serverIp": n.ServerIP, "serverIpV4": nullableString(n.ServerIPV4), "serverIpV6": nullableString(n.ServerIPV6), "extraIPs": nullableString(n.ExtraIPs), @@ -1819,7 +1820,7 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) { for _, n := range nodes { b := model.NodeBackup{ ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP, - Remark: n.Remark.String, Tags: n.Tags.String, + Remark: n.Remark.String, Tags: n.Tags.String, RenewalCycle: n.RenewalCycle.String, Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks, CreatedTime: n.CreatedTime, Status: n.Status, TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr, @@ -2180,6 +2181,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""}, Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""}, ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0}, + RenewalCycle: sql.NullString{String: n.RenewalCycle, Valid: n.RenewalCycle != ""}, Secret: n.Secret, ServerIP: n.ServerIP, ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true}, @@ -2204,7 +2206,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ - "name", "remark", "tags", "expiry_time", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version", + "name", "remark", "tags", "expiry_time", "renewal_cycle", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version", "http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr", "inx", "is_remote", "remote_url", "remote_token", "remote_config", }), diff --git a/go-backend/internal/store/repo/repository_migrate_test.go b/go-backend/internal/store/repo/repository_migrate_test.go index d3033e5..55859a4 100644 --- a/go-backend/internal/store/repo/repository_migrate_test.go +++ b/go-backend/internal/store/repo/repository_migrate_test.go @@ -7,10 +7,57 @@ import ( "testing" gsqlite "github.com/glebarez/sqlite" + "go-backend/internal/store/model" "gorm.io/gorm" "gorm.io/gorm/logger" ) +func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(t *testing.T) { + db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + + if err := db.Exec(` + CREATE TABLE node ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + secret VARCHAR(100) NOT NULL, + server_ip VARCHAR(100) NOT NULL, + port TEXT NOT NULL, + interface_name VARCHAR(200), + version VARCHAR(100), + http INTEGER NOT NULL DEFAULT 0, + tls INTEGER NOT NULL DEFAULT 0, + socks INTEGER NOT NULL DEFAULT 0, + created_time INTEGER NOT NULL, + updated_time INTEGER, + status INTEGER NOT NULL + ) + `).Error; err != nil { + t.Fatalf("create legacy node table: %v", err) + } + + if err := prepareSQLiteLegacyColumns(db); err != nil { + t.Fatalf("prepareSQLiteLegacyColumns: %v", err) + } + + m := db.Migrator() + for _, field := range []string{"Remark", "Tags", "ExpiryTime", "RenewalCycle"} { + if !m.HasColumn(&model.Node{}, field) { + t.Fatalf("expected node.%s column to exist", field) + } + } +} + func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) { db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index 985b08f..76787e6 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -3,6 +3,7 @@ package repo import ( "database/sql" "errors" + "fmt" "sort" "strconv" "strings" @@ -196,7 +197,7 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil } -func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error { +func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } @@ -205,6 +206,7 @@ func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serve Remark: nullStringFromInterface(remark), Tags: nullStringFromInterface(tags), ExpiryTime: nullInt64FromInterface(expiryTime), + RenewalCycle: nullStringFromInterface(renewalCycle), Secret: secret, ServerIP: serverIP, ServerIPV4: nullStringFromInterface(serverIPV4), @@ -242,7 +244,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla return node.Status, node.HTTP, node.TLS, node.Socks, nil } -func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error { +func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } @@ -253,6 +255,7 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser "remark": nullStringFromInterface(remark), "tags": nullStringFromInterface(tags), "expiry_time": nullInt64FromInterface(expiryTime), + "renewal_cycle": nullStringFromInterface(renewalCycle), "server_ip": serverIP, "server_ip_v4": nullStringFromInterface(serverIPV4), "server_ip_v6": nullStringFromInterface(serverIPV6), @@ -1483,3 +1486,59 @@ func (r *Repository) ReplaceUserGroupsByUserID(userID int64, newGroupIDs []int64 } return affectedGroupIDs, nil } + +func (r *Repository) AdvanceNodeRenewalCycles(now int64) (int, error) { + if r == nil || r.db == nil { + return 0, nil + } + + var nodes []model.Node + if err := r.db.Where("renewal_cycle IS NOT NULL AND renewal_cycle != '' AND expiry_time IS NOT NULL").Find(&nodes).Error; err != nil { + return 0, fmt.Errorf("list nodes with renewal cycle: %w", err) + } + + advanced := 0 + for _, node := range nodes { + if !node.ExpiryTime.Valid || node.ExpiryTime.Int64 <= 0 { + continue + } + + cycleMonths := 0 + switch node.RenewalCycle.String { + case "month": + cycleMonths = 1 + case "quarter": + cycleMonths = 3 + case "year": + cycleMonths = 12 + default: + continue + } + + anchorTime := node.ExpiryTime.Int64 + for anchorTime <= now { + nextAnchor := advanceByMonths(anchorTime, cycleMonths) + if nextAnchor <= anchorTime { + break + } + anchorTime = nextAnchor + } + + if anchorTime == node.ExpiryTime.Int64 { + continue + } + + if err := r.db.Model(&model.Node{}).Where("id = ?", node.ID).Update("expiry_time", anchorTime).Error; err != nil { + continue + } + advanced++ + } + + return advanced, nil +} + +func advanceByMonths(timestamp int64, months int) int64 { + t := time.Unix(timestamp/1000, 0) + next := t.AddDate(0, months, 0) + return next.UnixMilli() +} diff --git a/plans/024-node-renewal-cycle-and-schema-fix.md b/plans/024-node-renewal-cycle-and-schema-fix.md new file mode 100644 index 0000000..0237368 --- /dev/null +++ b/plans/024-node-renewal-cycle-and-schema-fix.md @@ -0,0 +1,7 @@ +# Node Renewal Cycle And Schema Fix Plan + +- [x] Review the node schema migration path and current expiry implementation +- [x] Backfill legacy node tables with the new metadata columns so old SQLite installs do not fail +- [x] Replace one-off node expiry UX with recurring renewal cycle fields (month/quarter/year) +- [x] Update node reminders and dashboard cards to use recurring renewal calculations +- [x] Verify backend and frontend changes, then complete the plan diff --git a/plans/025-node-renewal-auto-advance.md b/plans/025-node-renewal-auto-advance.md new file mode 100644 index 0000000..9e090d8 --- /dev/null +++ b/plans/025-node-renewal-auto-advance.md @@ -0,0 +1,7 @@ +# Node Renewal Auto-Advance Plan + +- [x] Review existing background job infrastructure and decide integration points +- [x] Add Repository method to advance node renewal anchor times +- [x] Add backend background worker that runs every 6 hours to advance overdue cycles +- [x] Add unit tests for renewal cycle advancement logic +- [x] Run backend verification and update plan checklist diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index 9fd44e0..8fd8893 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -6,6 +6,7 @@ export interface NodeApiItem { remark?: string; tags?: string; expiryTime?: number; + renewalCycle?: "month" | "quarter" | "year" | ""; syncError?: string; [key: string]: unknown; } @@ -221,6 +222,7 @@ export interface NodeMutationPayload { remark?: string; tags?: string; expiryTime?: number; + renewalCycle?: "month" | "quarter" | "year" | ""; serverIp?: string; serverIpV4?: string; serverIpV6?: string; diff --git a/vite-frontend/src/pages/dashboard.tsx b/vite-frontend/src/pages/dashboard.tsx index f6f15e3..8ebb67f 100644 --- a/vite-frontend/src/pages/dashboard.tsx +++ b/vite-frontend/src/pages/dashboard.tsx @@ -14,6 +14,11 @@ import { PageEmptyState, PageLoadingState } from "@/components/page-state"; import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner"; import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card"; import { MetricCard } from "@/pages/dashboard/components/metric-card"; +import { + formatNodeRenewalTime, + getNodeRenewalCycleLabel, + getNodeRenewalSnapshot, +} from "@/pages/node/renewal"; import { useDashboardData, type DashboardForward as Forward, @@ -72,23 +77,27 @@ export default function DashboardPage() { return value.toString(); }; - const getNodeExpiryStatus = (expiryTime?: number) => { - if (!expiryTime) { + const getNodeExpiryStatus = ( + nextDueTime?: number, + renewalState: "unset" | "expired" | "dueSoon" | "scheduled" = "unset", + ) => { + if (!nextDueTime || renewalState === "unset") { return { - label: "永久有效", + label: "未设置", badgeClassName: "bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300", + nextDueTime: undefined as number | undefined, }; } - const diffMs = expiryTime - Date.now(); - const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + const diffDays = Math.ceil((nextDueTime - Date.now()) / (1000 * 60 * 60 * 24)); - if (diffDays <= 0) { + if (renewalState === "expired" || diffDays <= 0) { return { - label: "已过期", + label: "已逾期", badgeClassName: "bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300", + nextDueTime, }; } @@ -97,6 +106,7 @@ export default function DashboardPage() { label: "明天到期", badgeClassName: "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300", + nextDueTime, }; } @@ -106,6 +116,7 @@ export default function DashboardPage() { diffDays <= 7 ? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300" : "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300", + nextDueTime, }; }; @@ -119,7 +130,14 @@ export default function DashboardPage() { }; const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => { - const expiryStatus = getNodeExpiryStatus(node.expiryTime); + const renewalSnapshot = getNodeRenewalSnapshot( + node.expiryTime, + node.renewalCycle, + ); + const expiryStatus = getNodeExpiryStatus( + renewalSnapshot.nextDueTime, + renewalSnapshot.state, + ); const tags = parseNodeTags(node.tags); return ( @@ -142,9 +160,11 @@ export default function DashboardPage() {
- {node.expiryTime - ? new Date(node.expiryTime).toLocaleString() - : "未设置到期时间"} + {formatNodeRenewalTime(renewalSnapshot.nextDueTime)} +
+ +
+ {getNodeRenewalCycleLabel(node.renewalCycle)}
{node.remark?.trim() && ( @@ -784,7 +804,7 @@ export default function DashboardPage() { 节点到期提醒

- 展示 7 天内到期或已过期的节点,便于提前续费或清理 + 展示 7 天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算

diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts index b5d644b..5d32ee0 100644 --- a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts +++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts @@ -9,6 +9,7 @@ import { getUserPackageInfo, type AnnouncementData, } from "@/api"; +import { getNodeRenewalSnapshot } from "@/pages/node/renewal"; import { getAdminFlag } from "@/utils/session"; export interface DashboardUserInfo { @@ -59,8 +60,17 @@ export interface DashboardNodeExpiryItem { remark?: string; tags?: string; expiryTime?: number; + renewalCycle?: "month" | "quarter" | "year" | ""; } +const normalizeDashboardRenewalCycle = ( + value: unknown, +): DashboardNodeExpiryItem["renewalCycle"] => { + return value === "month" || value === "quarter" || value === "year" + ? value + : ""; +}; + const DASHBOARD_POLL_INTERVAL_MS = 5000; const EXPIRATION_NOTIFICATION_STORAGE_KEY = "dashboard:last-expiration-notification"; @@ -213,16 +223,25 @@ const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => { name: item.name || "", remark: typeof item.remark === "string" ? item.remark : "", tags: typeof item.tags === "string" ? item.tags : "", + renewalCycle: normalizeDashboardRenewalCycle(item.renewalCycle), expiryTime: typeof item.expiryTime === "number" && item.expiryTime > 0 ? item.expiryTime : undefined, })) .filter((item) => { - if (!item.expiryTime) return false; - return item.expiryTime <= now + warningWindowMs; + if (!item.expiryTime || !item.renewalCycle) return false; + const snapshot = getNodeRenewalSnapshot(item.expiryTime, item.renewalCycle); + + if (!snapshot.nextDueTime) return false; + return snapshot.nextDueTime <= now + warningWindowMs; }) - .sort((a, b) => (a.expiryTime || 0) - (b.expiryTime || 0)); + .sort((a, b) => { + const aDue = getNodeRenewalSnapshot(a.expiryTime, a.renewalCycle).nextDueTime || 0; + const bDue = getNodeRenewalSnapshot(b.expiryTime, b.renewalCycle).nextDueTime || 0; + + return aDue - bDue; + }); }; export const useDashboardData = (): DashboardDataState => { diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index d18be2d..dd2b310 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -59,6 +59,12 @@ import { getRemoteSyncErrorMessage, } from "@/pages/node/display"; import { tryCopyInstallCommand } from "@/pages/node/install-command"; +import { + formatNodeRenewalTime, + getNodeRenewalCycleLabel, + getNodeRenewalSnapshot, + type NodeRenewalCycle, +} from "@/pages/node/renewal"; import { buildNodeSystemInfo } from "@/pages/node/system-info"; import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers"; import { useNodeRealtime } from "@/pages/node/use-node-realtime"; @@ -74,6 +80,7 @@ interface Node { remark?: string; tags?: string; expiryTime?: number; + renewalCycle?: NodeRenewalCycle; ip: string; serverIp: string; serverIpV4?: string; @@ -111,6 +118,7 @@ interface NodeForm { remark: string; tags: string; expiryTime: number; + renewalCycle: NodeRenewalCycle; serverHost: string; serverIpV4: string; serverIpV6: string; @@ -124,34 +132,33 @@ interface NodeForm { socks: number; // 0 关 1 开 } -const formatNodeExpiry = (timestamp?: number): string => { - if (!timestamp || timestamp <= 0) return "永久有效"; - return new Date(timestamp).toLocaleString(); -}; - const EXPIRING_SOON_DAYS = 7; type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired"; type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry"; -const getNodeExpiryMeta = (timestamp?: number) => { - if (!timestamp || timestamp <= 0) { +const getNodeReminderEnabled = (node: Node): boolean => { + return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle; +}; + +const getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => { + const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS); + + if (renewal.state === "unset") { return { state: "permanent" as NodeExpiryState, - label: "永久有效", + label: "未设置续费周期", tone: "default" as const, accentClassName: "", bannerClassName: "", isHighlighted: false, sortWeight: 3, + nextDueTime: undefined, }; } - const diffMs = timestamp - Date.now(); - const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); - - if (diffDays <= 0) { + if (renewal.state === "expired") { return { state: "expired" as NodeExpiryState, label: "已过期", @@ -162,13 +169,14 @@ const getNodeExpiryMeta = (timestamp?: number) => { "bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300", isHighlighted: true, sortWeight: 0, + nextDueTime: renewal.nextDueTime, }; } - if (diffDays <= EXPIRING_SOON_DAYS) { + if (renewal.state === "dueSoon") { return { state: "expiringSoon" as NodeExpiryState, - label: diffDays === 1 ? "明天到期" : `${diffDays}天后到期`, + label: renewal.label, tone: "warning" as const, accentClassName: "border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20", @@ -176,17 +184,19 @@ const getNodeExpiryMeta = (timestamp?: number) => { "bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300", isHighlighted: true, sortWeight: 1, + nextDueTime: renewal.nextDueTime, }; } return { state: "healthy" as NodeExpiryState, - label: `${diffDays}天后到期`, + label: renewal.label, tone: "success" as const, accentClassName: "", bannerClassName: "", isHighlighted: false, sortWeight: 2, + nextDueTime: renewal.nextDueTime, }; }; @@ -282,6 +292,7 @@ export default function NodePage() { remark: "", tags: "", expiryTime: 0, + renewalCycle: "", serverHost: "", serverIpV4: "", serverIpV6: "", @@ -664,6 +675,10 @@ export default function NodePage() { newErrors.name = "节点名称长度不能超过50位"; } + if ((form.renewalCycle && !form.expiryTime) || (!form.renewalCycle && form.expiryTime)) { + newErrors.expiryTime = "请同时设置续费周期和续费基准时间"; + } + const v4 = form.serverIpV4.trim(); const v6 = form.serverIpV6.trim(); const host = form.serverHost.trim(); @@ -726,6 +741,7 @@ export default function NodePage() { remark: node.remark || "", tags: node.tags || "", expiryTime: node.expiryTime || 0, + renewalCycle: node.renewalCycle || "", serverHost: normalizedHost, serverIpV4: normalizedV4, serverIpV6: normalizedV6, @@ -964,6 +980,7 @@ export default function NodePage() { remark: form.remark.trim(), tags: form.tags.trim(), expiryTime: form.expiryTime, + renewalCycle: form.renewalCycle, extraIPs: form.extraIPs, serverIp: form.serverIpV4?.trim() || @@ -988,6 +1005,7 @@ export default function NodePage() { remark: form.remark.trim(), tags: form.tags.trim(), expiryTime: form.expiryTime, + renewalCycle: form.renewalCycle, serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || @@ -1027,6 +1045,7 @@ export default function NodePage() { remark: "", tags: "", expiryTime: 0, + renewalCycle: "", serverHost: "", serverIpV4: "", serverIpV6: "", @@ -1162,11 +1181,13 @@ export default function NodePage() { const nodeExpiryStats = useMemo(() => { return nodeList.reduce( (acc, node) => { - const meta = getNodeExpiryMeta(node.expiryTime); + const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle); if (meta.state === "expired") acc.expired += 1; if (meta.state === "expiringSoon") acc.expiringSoon += 1; - if (node.expiryTime && node.expiryTime > 0) acc.withExpiry += 1; + if (getNodeReminderEnabled(node)) { + acc.withExpiry += 1; + } return acc; }, { expired: 0, expiringSoon: 0, withExpiry: 0 }, @@ -1195,7 +1216,7 @@ export default function NodePage() { if (nodeFilterMode !== "all") { filteredNodes = filteredNodes.filter((node) => { - const expiryMeta = getNodeExpiryMeta(node.expiryTime); + const expiryMeta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle); switch (nodeFilterMode) { case "expiringSoon": @@ -1203,7 +1224,7 @@ export default function NodePage() { case "expired": return expiryMeta.state === "expired"; case "withExpiry": - return !!node.expiryTime && node.expiryTime > 0; + return getNodeReminderEnabled(node); default: return true; } @@ -1212,8 +1233,8 @@ export default function NodePage() { const sortedByDb = [...filteredNodes].sort((a, b) => { const expiryDiff = - getNodeExpiryMeta(a.expiryTime).sortWeight - - getNodeExpiryMeta(b.expiryTime).sortWeight; + getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight - + getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight; if (expiryDiff !== 0) { return expiryDiff; @@ -1286,13 +1307,13 @@ export default function NodePage() { 全部节点 - 7天内到期 ({nodeExpiryStats.expiringSoon}) + 7天内续费 ({nodeExpiryStats.expiringSoon}) - 已过期 ({nodeExpiryStats.expired}) + 已逾期 ({nodeExpiryStats.expired}) - 已设置到期时间 ({nodeExpiryStats.withExpiry}) + 已启用续费提醒 ({nodeExpiryStats.withExpiry}) @@ -1405,7 +1426,10 @@ export default function NodePage() {
{sortedNodes.map((node) => { const isRemoteNode = node.isRemote === 1; - const expiryMeta = getNodeExpiryMeta(node.expiryTime); + const expiryMeta = getNodeExpiryMeta( + node.expiryTime, + node.renewalCycle, + ); return ( @@ -1514,12 +1538,12 @@ export default function NodePage() { )}
)} - {node.expiryTime && node.expiryTime > 0 && ( + {node.expiryTime && node.expiryTime > 0 && node.renewalCycle && (
- 到期时间 + 下次续费
- {formatNodeExpiry(node.expiryTime)} + {formatNodeRenewalTime(expiryMeta.nextDueTime)}
+
+ {getNodeRenewalCycleLabel(node.renewalCycle)} +
)} @@ -1860,27 +1887,74 @@ export default function NodePage() { } /> - 0 - ? new Date(form.expiryTime).toISOString().slice(0, 16) - : "" - } + + 0 + ? new Date(form.expiryTime).toISOString().slice(0, 16) + : "" + } + variant="bordered" + onChange={(e) => + setForm((prev) => ({ + ...prev, + expiryTime: e.target.value + ? new Date(e.target.value).getTime() + : 0, + })) + } + /> + + + + 0 + ? `当前按${getNodeRenewalCycleLabel(form.renewalCycle)}循环计算` + : "未启用循环续费提醒" + } + variant="bordered" + /> + { + const date = new Date(timestamp); + const next = new Date(date); + + next.setMonth(next.getMonth() + months); + return next.getTime(); +}; + +const cycleToMonths = (cycle: NodeRenewalCycle): number => { + switch (cycle) { + case "month": + return 1; + case "quarter": + return 3; + case "year": + return 12; + default: + return 0; + } +}; + +export const getNodeRenewalCycleLabel = (cycle?: string): string => { + switch (cycle) { + case "month": + return "月付"; + case "quarter": + return "季付"; + case "year": + return "年付"; + default: + return "未设置"; + } +}; + +export const getNodeRenewalSnapshot = ( + anchorTime?: number, + cycle?: string, + warningDays = 7, +): NodeRenewalSnapshot => { + const normalizedCycle = + cycle === "month" || cycle === "quarter" || cycle === "year" ? cycle : ""; + + if (!anchorTime || anchorTime <= 0 || !normalizedCycle) { + return { + cycle: normalizedCycle, + anchorTime: anchorTime && anchorTime > 0 ? anchorTime : undefined, + state: "unset", + label: "未设置续费周期", + }; + } + + const intervalMonths = cycleToMonths(normalizedCycle); + let nextDueTime = anchorTime; + + while (nextDueTime < Date.now()) { + const advanced = addMonths(nextDueTime, intervalMonths); + + if (advanced === nextDueTime) { + break; + } + nextDueTime = advanced; + } + + const diffDays = Math.ceil((nextDueTime - Date.now()) / (1000 * 60 * 60 * 24)); + + if (diffDays <= 0) { + return { + cycle: normalizedCycle, + anchorTime, + nextDueTime, + diffDays, + state: "expired", + label: "今天到期", + }; + } + + if (diffDays <= warningDays) { + return { + cycle: normalizedCycle, + anchorTime, + nextDueTime, + diffDays, + state: "dueSoon", + label: diffDays === 1 ? "明天续费" : `${diffDays}天后续费`, + }; + } + + return { + cycle: normalizedCycle, + anchorTime, + nextDueTime, + diffDays, + state: "scheduled", + label: `${diffDays}天后续费`, + }; +}; + +export const formatNodeRenewalTime = (timestamp?: number): string => { + if (!timestamp || timestamp <= 0) { + return "未设置"; + } + return new Date(timestamp).toLocaleString(); +};