diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx
index 35c2210..6273bcf 100644
--- a/src/components/NodeCard.tsx
+++ b/src/components/NodeCard.tsx
@@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import { Meter } from "@/components/Meter"
import type { Node } from "@/lib/api"
-import { bytes, CYCLES, daysUntil, FOREVER, money, osName, pair, percent, rate, uptime } from "@/lib/format"
+import { bytes, cycle, daysUntil, FOREVER, money, osName, pair, percent, rate, uptime } from "@/lib/format"
import { cn } from "@/lib/utils"
// Emitted as files and fetched on first use, so a page carries only the flags its
@@ -266,7 +266,7 @@ function Price({ node }: { node: Node }) {
{icon}
- {money(node.price, node.currency)} / {CYCLES[node.billing_cycle] ?? node.billing_cycle}
+ {money(node.price, node.currency)} / {cycle(node.billing_cycle)}
)
diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx
index d8be1fc..68e7df4 100644
--- a/src/components/NodeDetail.tsx
+++ b/src/components/NodeDetail.tsx
@@ -15,7 +15,7 @@ import { ChartTooltip, Crosshair, Swatch } from "@/components/ChartTooltip"
import { Country, Status } from "@/components/NodeCard"
import { api, type Node } from "@/lib/api"
import {
- axisBytes, axisTop, bytes, clockFor, despike, quarters, cpuName, CYCLES, FOREVER, money, osName, rate,
+ axisBytes, axisTop, bytes, clockFor, despike, quarters, cpuName, cycle, FOREVER, money, osName, rate,
stamp, timeTicks,
} from "@/lib/format"
import type { Availability } from "@/lib/uptime"
@@ -556,7 +556,7 @@ export function NodeDetail({ node, showPeak, onBack }: {
label="续费"
value={[
node.price > 0
- ? `${money(node.price, node.currency)} / ${CYCLES[node.billing_cycle] ?? node.billing_cycle}`
+ ? `${money(node.price, node.currency)} / ${cycle(node.billing_cycle)}`
: "免费",
node.expires_at ? `${node.expires_at} 到期` : FOREVER,
].join(" · ")}
diff --git a/src/components/NodeTable.tsx b/src/components/NodeTable.tsx
index 4950d69..d5e502c 100644
--- a/src/components/NodeTable.tsx
+++ b/src/components/NodeTable.tsx
@@ -3,7 +3,7 @@ import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"
import { Country, StatusDot, monthUsage, statusLabel } from "@/components/NodeCard"
import type { Node } from "@/lib/api"
-import { CYCLES, FOREVER, bytes, daysUntil, money, osName, pair, percent, rate } from "@/lib/format"
+import { cycle, FOREVER, bytes, daysUntil, money, osName, pair, percent, rate } from "@/lib/format"
import { cn } from "@/lib/utils"
type SortKey = "name" | "cpu" | "mem" | "disk" | "traffic" | "expiry"
@@ -107,7 +107,7 @@ const COLUMNS: {
className: "hidden lg:table-cell",
cell: (n) => (
- {n.price > 0 ? `${money(n.price, n.currency)} / ${CYCLES[n.billing_cycle] ?? n.billing_cycle}` : "免费"}
+ {n.price > 0 ? `${money(n.price, n.currency)} / ${cycle(n.billing_cycle)}` : "免费"}
),
},
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index 3ce3920..5510f1a 100644
--- a/src/lib/format.test.ts
+++ b/src/lib/format.test.ts
@@ -4,7 +4,7 @@
//
// Nothing imports it, so the bundle never includes it.
import {
- axisBytes, axisTop, bytes, cpuName, daysUntil, despike, osName, pair, quarters, timeTicks, uptime,
+ axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, osName, pair, quarters, timeTicks, uptime,
} from "./format.ts"
let failed = 0
@@ -121,6 +121,14 @@ eq(uptime(3 * 3600), "3 小时", "整小时的零头不写")
eq(osName("Debian GNU/Linux 12 (bookworm)"), "Debian 12", "发行版名去掉代号")
eq(cpuName("Intel(R) Xeon(R) CPU E5-2680 8-Core Processor"), "Intel Xeon E5-2680", "CPU 名去掉商标和核数")
+// 旧 hub 存名称,新 hub 把其余长度存成 `m`;同一个长度两种写法读出来一样,
+// 而认不出的周期原样显示,不吞掉。
+eq(
+ ["monthly", "yearly", "12m", "60m", "18m", "biennial", "36m", "once", "weekly"].map(cycle),
+ ["月", "年", "年", "5 年", "18 个月", "两年", "三年", "一次性", "weekly"],
+ "付款周期",
+)
+
if (failed) {
console.error(`\n${failed} 项不通过`)
throw new Error("format 校验未通过")
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 8e3bc5a..b9f5407 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -94,15 +94,19 @@ export function money(amount: number, currency: string): string {
* prints it after a slash -- `$39.90 / 年` is per year, and the "付" that used to
* follow it was the difference between the price fitting its column and being cut
* off with an ellipsis.
+ *
+ * Older hubs store only the named lengths below; later ones store any other
+ * length as `m`, which reads as `5 年` or `18 个月` in the same style.
*/
-export const CYCLES: Record = {
- monthly: "月",
- quarterly: "季",
- semiannual: "半年",
- yearly: "年",
- biennial: "两年",
- triennial: "三年",
- once: "一次性",
+const NAMED_CYCLES: Record = { monthly: 1, quarterly: 3, semiannual: 6, yearly: 12, biennial: 24, triennial: 36 }
+const CYCLE_WORDS: Record = { 1: "月", 3: "季", 6: "半年", 12: "年", 24: "两年", 36: "三年" }
+
+/** How a billing cycle reads after the slash: 月, 年, 5 年, 18 个月, 一次性. */
+export function cycle(billing: string): string {
+ if (billing === "once") return "一次性"
+ const months = NAMED_CYCLES[billing] ?? Number(/^(\d+)m$/.exec(billing)?.[1])
+ if (!months) return billing
+ return CYCLE_WORDS[months] ?? (months % 12 ? `${months} 个月` : `${months / 12} 年`)
}
// Hoisted out of `clock`: recharts calls a tickFormatter for every sample when