feat(nodes): add renewal cycle and auto-advance scheduling (#296)

- Add renewal_cycle field to nodes for tracking paid vs free cycles
- Implement auto-advance scheduling when renewal is processed
- Add migration test for renewal_cycle column
- Update dashboard to show renewal cycle count
- Add renewal status display on node detail page
This commit is contained in:
sagit
2026-03-08 22:05:25 +08:00
committed by GitHub
parent 508a37a84c
commit 413081f72a
14 changed files with 525 additions and 69 deletions
+2
View File
@@ -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;
+32 -12
View File
@@ -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() {
</div>
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
{node.expiryTime
? new Date(node.expiryTime).toLocaleString()
: "未设置到期时间"}
{formatNodeRenewalTime(renewalSnapshot.nextDueTime)}
</div>
<div className="mt-1 text-xs text-default-500">
{getNodeRenewalCycleLabel(node.renewalCycle)}
</div>
{node.remark?.trim() && (
@@ -784,7 +804,7 @@ export default function DashboardPage() {
节点到期提醒
</h2>
<p className="text-sm text-default-500">
展示 7 天内到期或已过期的节点,便于提前续费或清理
展示 7 天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算
</p>
</div>
</div>
@@ -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 => {
+118 -44
View File
@@ -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() {
全部节点
</SelectItem>
<SelectItem key="expiringSoon" textValue="7天内到期">
7天内到期 ({nodeExpiryStats.expiringSoon})
7天内续费 ({nodeExpiryStats.expiringSoon})
</SelectItem>
<SelectItem key="expired" textValue="已过期">
已过期 ({nodeExpiryStats.expired})
已逾期 ({nodeExpiryStats.expired})
</SelectItem>
<SelectItem key="withExpiry" textValue="已设置到期时间">
已设置到期时间 ({nodeExpiryStats.withExpiry})
已启用续费提醒 ({nodeExpiryStats.withExpiry})
</SelectItem>
</Select>
</div>
@@ -1405,7 +1426,10 @@ export default function NodePage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedNodes.map((node) => {
const isRemoteNode = node.isRemote === 1;
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
const expiryMeta = getNodeExpiryMeta(
node.expiryTime,
node.renewalCycle,
);
return (
<SortableItem key={node.id} id={node.id}>
@@ -1514,12 +1538,12 @@ export default function NodePage() {
)}
</div>
)}
{node.expiryTime && node.expiryTime > 0 && (
{node.expiryTime && node.expiryTime > 0 && node.renewalCycle && (
<div className="flex justify-between text-sm">
<span className="text-default-600">到期时间</span>
<span className="text-default-600">下次续费</span>
<div className="text-right ml-2">
<div className="text-xs text-warning-700 dark:text-warning-400">
{formatNodeExpiry(node.expiryTime)}
{formatNodeRenewalTime(expiryMeta.nextDueTime)}
</div>
<div className="mt-1">
<Chip
@@ -1531,6 +1555,9 @@ export default function NodePage() {
{expiryMeta.label}
</Chip>
</div>
<div className="mt-1 text-[11px] text-default-500">
{getNodeRenewalCycleLabel(node.renewalCycle)}
</div>
</div>
</div>
)}
@@ -1860,27 +1887,74 @@ export default function NodePage() {
}
/>
<Input
description="留空表示永久有效,可用于记录节点到期时间"
label="到期时间"
type="datetime-local"
value={
form.expiryTime > 0
? new Date(form.expiryTime).toISOString().slice(0, 16)
: ""
}
<Select
label="续费周期"
placeholder="选择续费周期"
selectedKeys={form.renewalCycle ? [form.renewalCycle] : []}
variant="bordered"
onChange={(e) =>
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as
| NodeRenewalCycle
| undefined;
setForm((prev) => ({
...prev,
expiryTime: e.target.value
? new Date(e.target.value).getTime()
: 0,
}))
}
/>
renewalCycle: selected || "",
}));
}}
>
<SelectItem key="month" textValue="month">
月付
</SelectItem>
<SelectItem key="quarter" textValue="quarter">
季付
</SelectItem>
<SelectItem key="year" textValue="year">
年付
</SelectItem>
</Select>
</div>
<Input
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
errorMessage={errors.expiryTime}
isInvalid={!!errors.expiryTime}
label="续费基准时间"
type="datetime-local"
value={
form.expiryTime > 0
? new Date(form.expiryTime).toISOString().slice(0, 16)
: ""
}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
expiryTime: e.target.value
? new Date(e.target.value).getTime()
: 0,
}))
}
/>
<Alert
color="primary"
description="例如选择月付并填写 2026-03-01,系统会自动按每月同日推算下次续费时间。"
variant="flat"
/>
<Input
description="留空表示未设置续费提醒;只有同时设置周期和基准时间才会参与提醒与筛选"
label="说明"
readOnly
value={
form.renewalCycle && form.expiryTime > 0
? `当前按${getNodeRenewalCycleLabel(form.renewalCycle)}循环计算`
: "未启用循环续费提醒"
}
variant="bordered"
/>
<Input
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
errorMessage={errors.serverHost}
+114
View File
@@ -0,0 +1,114 @@
export type NodeRenewalCycle = "" | "month" | "quarter" | "year";
export interface NodeRenewalSnapshot {
cycle: NodeRenewalCycle;
anchorTime?: number;
nextDueTime?: number;
diffDays?: number;
state: "unset" | "expired" | "dueSoon" | "scheduled";
label: string;
}
const addMonths = (timestamp: number, months: number): number => {
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();
};