diff --git a/src/components/ServerTable.tsx b/src/components/ServerTable.tsx
index 84a0fea..7ae5078 100644
--- a/src/components/ServerTable.tsx
+++ b/src/components/ServerTable.tsx
@@ -8,7 +8,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { groupsOf, type Node } from "@/lib/api"
import {
- bytes, compact, CYCLES, distro, duration, expiresIn, FOREVER, money, monthUsage, osName, cpuName, pair,
+ bytes, compact, cycle, distro, duration, expiresIn, FOREVER, money, monthUsage, osName, cpuName, pair,
percent, rate, uptime,
} from "@/lib/format"
import { Link } from "@/lib/route"
@@ -191,7 +191,7 @@ function Details({ node }: { node: Node }) {
{node.online ? (m ? uptime(m.uptime) : "等待上报") : away >= 60 ? uptime(away) : "刚刚"}
- {node.price > 0 ? `${money(node.price, node.currency)} / ${CYCLES[node.billing_cycle] ?? node.billing_cycle}` : "免费"}
+ {node.price > 0 ? `${money(node.price, node.currency)} / ${cycle(node.billing_cycle)}` : "免费"}
{node.expires_at
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index b1f3f20..2627a39 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, compact, cpuName, daysUntil, despike, distro, duration, expiresIn, monthUsage, osName, pair,
+ axisBytes, axisTop, bytes, compact, cpuName, cycle, daysUntil, despike, distro, duration, expiresIn, monthUsage, osName, pair,
quarters, timeTicks, uptime,
} from "./format.ts"
@@ -152,6 +152,9 @@ eq(duration(76 * 86400 + 5), "76 天", "超过一天只写天数")
eq(despike([20, 900, 20, 20, 20], 3)[1], 20, "窗口可以调小")
}
+// 旧 hub 存名称,新 hub 把其余长度存成 `m`;两种写法同一个长度读法一致。
+eq(["yearly", "12m", "60m", "18m", "once", "weekly"].map(cycle), ["年付", "年付", "5 年付", "18 个月付", "一次性", "weekly"], "付款周期")
+
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 名去掉商标和核数")
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 2639e3a..c222bbe 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -138,14 +138,17 @@ export function money(amount: number, currency: string): string {
return `${SYMBOLS[currency] ?? ""}${amount.toFixed(2)}${SYMBOLS[currency] ? "" : ` ${currency}`}`
}
-export const CYCLES: Record = {
- monthly: "月付",
- quarterly: "季付",
- semiannual: "半年付",
- yearly: "年付",
- biennial: "两年付",
- triennial: "三年付",
- once: "一次性",
+// Hub 1.3.0 and earlier store only these names; later hubs store any other
+// length as `m`.
+const NAMED_CYCLES: Record = { monthly: 1, quarterly: 3, semiannual: 6, yearly: 12, biennial: 24, triennial: 36 }
+const CYCLE_WORDS: Record = { 1: "月付", 3: "季付", 6: "半年付", 12: "年付" }
+
+/** How a billing cycle reads: 月付, 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