diff --git a/src/api.rs b/src/api.rs index 5cc146c0..be37e936 100644 --- a/src/api.rs +++ b/src/api.rs @@ -680,6 +680,27 @@ fn group_error(group: &mut String) -> Option<&'static str> { None } +/// Normalizes the currency and billing cycle, or names the one that cannot be +/// stored. The currency is held to the ISO 4217 form of three letters, the only +/// one `Intl.NumberFormat` accepts, so a theme may pass it on without guarding +/// against a throw. Each cycle length is stored in a single spelling, see +/// `cycle_name`. +fn billing_error(currency: Option<&mut String>, cycle: Option<&mut String>) -> Option<&'static str> { + if let Some(code) = currency { + *code = code.trim().to_ascii_uppercase(); + if !(code.len() == 3 && code.bytes().all(|b| b.is_ascii_uppercase())) { + return Some("货币要填三个字母的代码,如 USD、CNY、HKD"); + } + } + if let Some(cycle) = cycle.filter(|c| *c != "once") { + let Some(months) = crate::cycle_months(cycle) else { + return Some("付款周期要是整月,在 1 个月到 100 年之间"); + }; + *cycle = crate::cycle_name(months); + } + None +} + /// Normalizes a patch, or names the first value that cannot be stored. The one /// check both the single and the batch write pass through, so the two accept /// exactly the same values. @@ -695,7 +716,9 @@ fn patch_error(node: &mut NodePatch) -> Option<&'static str> { return Some(message); } } - node_limits(node.traffic_reset_day, node.price, node.traffic_limit).or_else(|| pins(node)) + node_limits(node.traffic_reset_day, node.price, node.traffic_limit) + .or_else(|| billing_error(node.currency.as_mut(), node.billing_cycle.as_mut())) + .or_else(|| pins(node)) } /// Normalizes the values set by hand, or names the one that cannot stand. Each @@ -762,6 +785,7 @@ pub async fn create_node( if let Some(message) = node_limits(Some(node.traffic_reset_day), Some(node.price), Some(node.traffic_limit)) .or_else(|| group_error(&mut node.group)) + .or_else(|| billing_error(Some(&mut node.currency), Some(&mut node.billing_cycle))) { return bad(message); } @@ -2871,6 +2895,11 @@ mod tests { json!({"name": "x", "traffic_reset_day": 99}), json!({"name": "x", "price": -5.0}), json!({"name": "x", "traffic_limit": -1}), + json!({"name": "x", "currency": "USDT"}), + json!({"name": "x", "currency": "港币"}), + json!({"name": "x", "billing_cycle": "0m"}), + json!({"name": "x", "billing_cycle": "1201m"}), + json!({"name": "x", "billing_cycle": "weekly"}), ] { let created = create_node( Admin, @@ -2892,6 +2921,29 @@ mod tests { assert_eq!(app.db.nodes().unwrap().len(), 1, "nothing was created"); } + /// A length with a name is stored under it, so a theme built for hub 1.3.0 + /// still labels it. + #[tokio::test] + async fn currency_and_cycle_are_stored_in_one_spelling() { + let app = std::sync::Arc::new(app()); + let id = node(&app, "n", true); + for (sent, currency, cycle) in [ + (json!({"currency": " hkd ", "billing_cycle": "60m"}), "HKD", "60m"), + (json!({"billing_cycle": "12m"}), "HKD", "yearly"), + (json!({"billing_cycle": "once"}), "HKD", "once"), + ] { + let put = update_node( + Admin, + State(app.clone()), + Path(id), + Ok(Json(serde_json::from_value(sent).unwrap())), + ); + assert_eq!(put.await.status(), StatusCode::OK); + let stored = app.db.node(id).unwrap().unwrap(); + assert_eq!((stored.currency.as_str(), stored.billing_cycle.as_str()), (currency, cycle)); + } + } + /// A stream outlives the request that opened it, so everything the handshake /// tested must be re-read rather than captured -- both answers, not one. The /// admin frame carries every node's token in the clear, and the public frame diff --git a/src/main.rs b/src/main.rs index b7223f42..1fa69359 100644 --- a/src/main.rs +++ b/src/main.rs @@ -673,17 +673,34 @@ fn new_password(db: &Db) -> Result { Ok(password) } +/// Cycles stored under a name. Any other length is stored as `m`; the names +/// remain because themes built for hub 1.3.0 and earlier recognize only these. +const NAMED_CYCLES: [(&str, u32); 6] = [ + ("monthly", 1), + ("quarterly", 3), + ("semiannual", 6), + ("yearly", 12), + ("biennial", 24), + ("triennial", 36), +]; + +/// The longest cycle accepted, in months: 100 years. +const MAX_CYCLE: u32 = 1_200; + /// Billing cycles as whole months. `once` has none, so it never rolls over. fn cycle_months(cycle: &str) -> Option { - Some(match cycle { - "monthly" => 1, - "quarterly" => 3, - "semiannual" => 6, - "yearly" => 12, - "biennial" => 24, - "triennial" => 36, - _ => return None, - }) + match NAMED_CYCLES.iter().find(|(name, _)| *name == cycle) { + Some(&(_, months)) => Some(months), + None => cycle.strip_suffix('m')?.parse().ok().filter(|m| (1..=MAX_CYCLE).contains(m)), + } +} + +/// The stored spelling of a cycle of `months`: its name where it has one. +fn cycle_name(months: u32) -> String { + NAMED_CYCLES + .iter() + .find(|(_, m)| *m == months) + .map_or_else(|| format!("{months}m"), |(name, _)| (*name).into()) } /// A node still reporting past its expiry date has been renewed, so the date is @@ -798,6 +815,8 @@ mod tests { assert_eq!(renewed(d("2026-01-31"), "monthly", d("2026-02-01")), Some(d("2026-02-28"))); // Years overdue: cycles are added until the date is in the future. assert_eq!(renewed(d("2024-03-10"), "yearly", d("2026-08-28")), Some(d("2027-03-10"))); + // A length without a name rolls the same way. + assert_eq!(renewed(d("2026-03-10"), "60m", d("2026-08-28")), Some(d("2031-03-10"))); // Not yet due, and one-off billing: both left unchanged. assert_eq!(renewed(d("2026-09-01"), "monthly", d("2026-08-28")), None); assert_eq!(renewed(d("2020-01-01"), "once", d("2026-08-28")), None); diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 922b2b31..7168b5bc 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useEffect, useId, useRef, useState } from "react" import { flushSync } from "react-dom" -import { ArrowUpCircle, Bell, CalendarClock, Check, ChevronDown, ChevronRight, Copy, Database, Download, GripVertical, Layers, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, SlidersHorizontal, Trash2, Upload } from "lucide-react" +import { ArrowUpCircle, Bell, CalendarClock, Check, ChevronDown, ChevronRight, CircleQuestionMark, Copy, Database, Download, GripVertical, Layers, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, SlidersHorizontal, Trash2, Upload } from "lucide-react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" @@ -15,7 +15,7 @@ import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { api, badIfaceName, behind, changes, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, groupsOf, ifaceChoice, ifaceSpec, inGroup, outdatedAgents, provisioningSite, shortAddress, trafficCorrection, upload, type ConfigField, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" -import { bytes, CYCLES, FOREVER, money, uptime } from "@/lib/format" +import { bytes, cycleMonths, FOREVER, money, uptime } from "@/lib/format" // Counters the panel can correct after migration or an accounting error. const TRAFFIC_FIELDS = [ @@ -377,16 +377,48 @@ function Command({ className = "", children }: { className?: string; children: R ) } -function Field({ label, hint, className = "", children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) { +function Field({ label, hint, help, className = "", children }: { + label: string + hint?: string + help?: React.ReactNode + className?: string + children: React.ReactNode +}) { + const title = return (
- + {help ?
{title}{help}
: title} {children} {hint &&

{hint}

}
) } +// A tap shows no tooltip on its own, so a click opens it as well. The trigger's +// own handlers would close it on press and on click; both are prevented. +function Help({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false) + return ( + + + + + {children} + + ) +} + // A titled option with its control at the right. `toggle` makes the whole row a // label, so a click anywhere flips the Switch it holds; `below` opens beneath it // in the same card. @@ -855,6 +887,20 @@ function NodeForm({ node, nodes, onClose, onSaved }: { ) } +const CURRENCY_NAMES = new Intl.DisplayNames(["zh-CN"], { type: "currency" }) + +// The name confirms a code the hub can only check the shape of. DisplayNames +// echoes back a code outside ISO 4217 and throws on anything but three letters, +// which the hub refuses with its own message. +function currencyHint(code: string) { + try { + const name = CURRENCY_NAMES.of(code) + return name === code ? "未知代码,照原样显示" : name + } catch { + return undefined + } +} + function BillingForm({ node, onClose, onSaved }: { node: Node onClose: () => void @@ -864,10 +910,17 @@ function BillingForm({ node, onClose, onSaved }: { // Text rather than a number: a numeric state cannot represent an empty field, // so clearing it would snap back to 0 mid-entry. Empty means free. const [price, setPrice] = useState(node.price > 0 ? String(node.price) : "") + // Whole years are entered in years, the way a five-year plan is sold. + const months = cycleMonths(node.billing_cycle) + const [unit, setUnit] = useState(months === 0 ? "once" : months % 12 ? "months" : "years") + const [count, setCount] = useState(String(months % 12 ? months : months / 12 || 1)) const [saving, setSaving] = useState(false) const set = (k: K, v: Node[K]) => setForm((f) => ({ ...f, [k]: v })) async function save() { + // The hub refuses a length out of range and stores a named one by name, so + // an unchanged length is compared in months, not in spelling. + const cycle = unit === "once" ? "once" : `${Number(count) * (unit === "years" ? 12 : 1)}m` setSaving(true) try { await api(`/nodes/${node.id}`, { @@ -875,7 +928,7 @@ function BillingForm({ node, onClose, onSaved }: { body: JSON.stringify(changes(node, { price: Math.max(0, Number(price) || 0), currency: form.currency, - billing_cycle: form.billing_cycle, + billing_cycle: cycleMonths(cycle) === months ? node.billing_cycle : cycle, expires_at: form.expires_at || null, })), }) @@ -908,27 +961,57 @@ function BillingForm({ node, onClose, onSaved }: { placeholder="免费" /> - - + +

填三个字母的货币代码,大小写都行。

+

+ 例如: + {["美元 USD", "人民币 CNY", "港币 HKD", "新台币 TWD", "欧元 EUR", "日元 JPY"].map((c, i) => ( + {i > 0 && "、"}{c} + ))} +

+ + } + > + {/* Uppercased by CSS: rewriting the value mid-composition would + break an input method, and the hub stores it uppercased. */} + set("currency", e.target.value)} + placeholder="USD" + />
- +
+ {unit !== "once" && ( + setCount(e.target.value)} + /> + )} + +
set("expires_at", e.target.value)} /> diff --git a/web-admin/src/lib/format.ts b/web-admin/src/lib/format.ts index 23a2fecc..eab87797 100644 --- a/web-admin/src/lib/format.ts +++ b/web-admin/src/lib/format.ts @@ -37,12 +37,10 @@ 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: "一次性", +// The hub stores these lengths under a name and any other as `m`. +const NAMED_CYCLES: Record = { monthly: 1, quarterly: 3, semiannual: 6, yearly: 12, biennial: 24, triennial: 36 } + +/** A billing cycle in months: 0 for one-off, NaN when unrecognized. */ +export function cycleMonths(cycle: string): number { + return cycle === "once" ? 0 : NAMED_CYCLES[cycle] ?? Number(/^(\d+)m$/.exec(cycle)?.[1]) }