+
}>{formatBytes(server.net_tx_total)}
}>{formatBytes(server.net_rx_total)}
- {showExpiryPanel ?
+ {showExpiryPanel ?
{config.show_expiry ? }>{formatExpire(server, locale)} : null}
{config.show_price ? }>{server.price === -1
? ui(locale, "免费", "Free")
@@ -172,6 +170,13 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
>}
: null}
+
+ {!online ?
+ {ui(locale, "离线", "Offline")}
+ {server.timestamp
+ ? ui(locale, `最后更新 ${lastUpdated}`, `Last update ${lastUpdated}`)
+ : ui(locale, "暂无更新时间", "No update yet")}
+
: null}
);
diff --git a/frontend/src/components/NodeDetails.tsx b/frontend/src/components/NodeDetails.tsx
index aeb8ba3..d8d0c9b 100644
--- a/frontend/src/components/NodeDetails.tsx
+++ b/frontend/src/components/NodeDetails.tsx
@@ -132,7 +132,6 @@ interface LatencyChartPoint {
interface LoadChartPoint {
timestamp: number;
- time: string;
cpu: number | null;
mem_used: number | null;
mem_total: number;
@@ -150,19 +149,12 @@ function insertLatencyGaps(points: LatencyChartPoint[], hours: number): LatencyC
);
}
-function loadChartTime(timestamp: number, hours: number, locale: "zh-CN" | "en"): string {
- return new Date(timestamp * 1000).toLocaleString(locale, hours >= 24
- ? { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }
- : { hour: "2-digit", minute: "2-digit", hour12: false });
-}
-
-function insertLoadGaps(points: HistoryPoint[], hours: number, locale: "zh-CN" | "en"): LoadChartPoint[] {
+function insertLoadGaps(points: HistoryPoint[], hours: number): LoadChartPoint[] {
const mapped = points
.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0)
.sort((left, right) => left.timestamp - right.timestamp)
.map((point) => ({
- timestamp: point.timestamp,
- time: loadChartTime(point.timestamp, hours, locale),
+ timestamp: point.timestamp * 1000,
cpu: Number.isFinite(point.cpu) ? point.cpu : null,
mem_used: Number.isFinite(point.mem_used) ? point.mem_used : null,
mem_total: Math.max(0, number(point.mem_total)),
@@ -171,13 +163,10 @@ function insertLoadGaps(points: HistoryPoint[], hours: number, locale: "zh-CN" |
net_in: Number.isFinite(point.net_in) ? point.net_in : null,
net_out: Number.isFinite(point.net_out) ? point.net_out : null,
}));
- // Load history keeps second-resolution timestamps, so the gap bounds are
- // the millisecond limits divided back down.
return insertTimelineGaps(
mapped,
(timestamp) => ({
timestamp,
- time: "",
cpu: null,
mem_used: null,
mem_total: 0,
@@ -186,7 +175,7 @@ function insertLoadGaps(points: HistoryPoint[], hours: number, locale: "zh-CN" |
net_in: null,
net_out: null,
}),
- { minGap: 10, maxGap: chartGapLimit(hours) / 1000 },
+ { minGap: 10_000, maxGap: chartGapLimit(hours) },
);
}
@@ -241,12 +230,6 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
return () => { active = false; };
}, [server.id, loadHours, demo, locale]);
- useEffect(() => {
- if (demo || loadHours !== 0) return;
- const point = historyPointFromServer(server);
- if (point) setPoints((current) => appendRealtimePoint(current, point));
- }, [demo, loadHours, server]);
-
useEffect(() => {
setLatencyLoading(true);
setLatencyError("");
@@ -331,8 +314,15 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
const hours = chartType === "load" ? loadHours : latencyHours;
const loadChartHours = loadHours === 0 ? 1 : loadHours;
const data = useMemo(
- () => insertLoadGaps(points, loadChartHours, locale),
- [loadChartHours, locale, points],
+ () => insertLoadGaps(points, loadChartHours),
+ [loadChartHours, points],
+ );
+ // 有时间范围时钉住整段窗口,部分数据也保持真实比例;实时档跟随数据自适应
+ // (与延迟图、Komari 原版一致)。points 进 deps 是为了在数据落位后重算
+ // domain,慢查询时末端不会被裁掉。
+ const loadTimeRange = useMemo(
+ () => (loadHours === 0 ? null : chartTimeRange(loadChartHours)),
+ [loadChartHours, loadHours, points],
);
const latencySeries = useMemo(() => {
return latencyTasks.map((task, index) => {
@@ -426,16 +416,16 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
{chartType === "load" && loadLoading ?
{ui(locale, "正在读取历史数据", "Loading history")}
: chartType === "latency" && latencyLoading ?
{ui(locale, "正在读取延迟数据", "Loading latency")}
: chartType === "load" && !data.length ?
{ui(locale, "暂无历史数据", "No history")}
: chartType === "load" ? (
- [`${Number(value).toFixed(1)}%`, "CPU"]} />
+ 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /> chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [`${Number(value).toFixed(1)}%`, "CPU"]} />
- formatBytes(Number(value), 0)} /> [formatBytes(Number(value)), "内存"]} />
+ 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /> formatBytes(Number(value), 0)} /> chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "内存"]} />
- formatBytes(Number(value), 0)} /> [formatBytes(Number(value)), "硬盘"]} />
+ 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /> formatBytes(Number(value), 0)} /> chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "硬盘"]} />
- formatBytes(Number(value), 0)} /> [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} />
+ 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /> formatBytes(Number(value), 0)} /> chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} />
) : (
diff --git a/frontend/src/format.test.ts b/frontend/src/format.test.ts
new file mode 100644
index 0000000..1055091
--- /dev/null
+++ b/frontend/src/format.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, test } from "bun:test";
+import { formatByteSize, formatPrice, parseByteSize } from "./format";
+
+describe("parseByteSize", () => {
+ test("accepts manual units and defaults to GB", () => {
+ expect(parseByteSize("100 G")).toBe(100 * 1024 ** 3);
+ expect(parseByteSize("0.5T")).toBe(0.5 * 1024 ** 4);
+ expect(parseByteSize("512 m")).toBe(512 * 1024 ** 2);
+ expect(parseByteSize("2048 kb")).toBe(2048 * 1024);
+ expect(parseByteSize("200")).toBe(200 * 1024 ** 3);
+ });
+
+ test("rejects malformed input", () => {
+ expect(parseByteSize("100 X")).toBeNull();
+ expect(parseByteSize("abc")).toBeNull();
+ expect(parseByteSize("")).toBeNull();
+ expect(parseByteSize("-5 G")).toBeNull();
+ expect(parseByteSize("-1")).toBeNull();
+ });
+});
+
+describe("formatByteSize", () => {
+ test("round-trips through parseByteSize", () => {
+ for (const bytes of [0, 512 * 1024 ** 2, 100 * 1024 ** 3, 2 * 1024 ** 4]) {
+ expect(parseByteSize(formatByteSize(bytes))).toBe(bytes);
+ }
+ });
+
+ test("passes the unlimited sentinel through unchanged", () => {
+ expect(formatByteSize(-1)).toBe("-1");
+ });
+});
+
+describe("formatPrice", () => {
+ test("renders free, hidden and one-time cycles", () => {
+ expect(formatPrice({ price: -1, billing_cycle: 30, currency: "CNY" })).toBe("免费");
+ expect(formatPrice({ price: 0, billing_cycle: 30, currency: "CNY" })).toBe("");
+ expect(formatPrice({ price: 5, billing_cycle: 0, currency: "CNY" })).toBe("¥5 / 一次性");
+ expect(formatPrice({ price: 5, billing_cycle: 365, currency: "CNY" })).toBe("¥5 / 年");
+ });
+});
diff --git a/frontend/src/format.ts b/frontend/src/format.ts
index 676a6ae..a8959b0 100644
--- a/frontend/src/format.ts
+++ b/frontend/src/format.ts
@@ -22,6 +22,35 @@ export function formatSpeed(value: number | null | undefined) {
return `${formatBytes(value)}/s`;
}
+// 节点编辑框的手动单位输入:"100 G"、"0.5 T";不带单位按 GB。
+const BYTE_UNIT_FACTORS: Record
= {
+ "": 1024 ** 3,
+ b: 1,
+ k: 1024, kb: 1024, kib: 1024,
+ m: 1024 ** 2, mb: 1024 ** 2, mib: 1024 ** 2,
+ g: 1024 ** 3, gb: 1024 ** 3, gib: 1024 ** 3,
+ t: 1024 ** 4, tb: 1024 ** 4, tib: 1024 ** 4,
+};
+
+/** 解析 "100 G" 风格的流量输入为字节数,非法输入返回 null。 */
+export function parseByteSize(text: string): number | null {
+ const match = /^(\d+(?:\.\d+)?)\s*([a-zA-Z]*)$/.exec(text.trim());
+ if (!match) return null;
+ const factor = BYTE_UNIT_FACTORS[match[2].toLowerCase()];
+ return factor === undefined ? null : Math.round(Number(match[1]) * factor);
+}
+
+/** 字节数 → "100 G" 风格短文本,编辑框失焦时回显;-1 是「不限」哨兵,原样回显。 */
+export function formatByteSize(bytes: number): string {
+ if (bytes < 0) return "-1";
+ const size = Math.max(0, Math.round(number(bytes)));
+ if (size === 0) return "0";
+ for (const [suffix, factor] of [["T", 1024 ** 4], ["G", 1024 ** 3], ["M", 1024 ** 2], ["K", 1024]] as const) {
+ if (size >= factor) return `${Math.round((size / factor) * 100) / 100} ${suffix}`;
+ }
+ return `${size} B`;
+}
+
export function formatCpuName(model: string | null | undefined, cores: number | null | undefined) {
const name = (model ?? "").replace(/\s+/g, " ").trim() || "--";
const count = Math.max(0, Math.trunc(number(cores)));
@@ -81,15 +110,6 @@ export function isOnline(server: Pick, threshold: number, a
return !!server.timestamp && at - server.timestamp <= threshold;
}
-export function timeAgo(timestamp: number | null, locale: UiLocale = "zh-CN") {
- if (!timestamp) return ui(locale, "尚未上报", "never reported");
- const seconds = Math.max(0, Math.floor(Date.now() / 1000 - timestamp));
- if (seconds < 60) return ui(locale, `${seconds} 秒前`, `${seconds}s ago`);
- if (seconds < 3600) return ui(locale, `${Math.floor(seconds / 60)} 分钟前`, `${Math.floor(seconds / 60)}m ago`);
- if (seconds < 86400) return ui(locale, `${Math.floor(seconds / 3600)} 小时前`, `${Math.floor(seconds / 3600)}h ago`);
- return ui(locale, `${Math.floor(seconds / 86400)} 天前`, `${Math.floor(seconds / 86400)}d ago`);
-}
-
export function countryFlag(region: string) {
const code = region.trim().slice(0, 2).toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return "";
@@ -123,7 +143,8 @@ export function formatPrice(server: Pick= 27 && server.billing_cycle <= 32 ? ui(locale, "月", "month")
+ const cycle = server.billing_cycle <= 0 ? ui(locale, "一次性", "one-time")
+ : server.billing_cycle >= 27 && server.billing_cycle <= 32 ? ui(locale, "月", "month")
: server.billing_cycle >= 87 && server.billing_cycle <= 95 ? ui(locale, "季", "quarter")
: server.billing_cycle >= 175 && server.billing_cycle <= 185 ? ui(locale, "半年", "half-year")
: server.billing_cycle >= 360 && server.billing_cycle <= 370 ? ui(locale, "年", "year")
diff --git a/frontend/src/hooks/useNodeLatency.ts b/frontend/src/hooks/useNodeLatency.ts
index ebda9d7..c5056d4 100644
--- a/frontend/src/hooks/useNodeLatency.ts
+++ b/frontend/src/hooks/useNodeLatency.ts
@@ -152,8 +152,9 @@ export function useNodeLatency(
}
return {
- latencyDisplay: averageLatency === null ? placeholder : `${Math.round(averageLatency)} ms`,
- lossDisplay: averageLoss === null ? placeholder : `${averageLoss.toFixed(1)}%`,
+ // 与 Komari 原版一致:无数据时汇总显示 "-",「无采样数据」只出现在空柱的 tooltip 里。
+ latencyDisplay: averageLatency === null ? (loading ? placeholder : "-") : `${Math.round(averageLatency)} ms`,
+ lossDisplay: averageLoss === null ? (loading ? placeholder : "-") : `${averageLoss.toFixed(1)}%`,
latencyBars: buckets.length ? latencyBars(buckets, "all", locale) : emptyBars(placeholder),
lossBars: buckets.length ? lossBars(buckets, "all", locale) : emptyBars(placeholder),
carriers,
diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css
index ca8e2df..753e4b0 100644
--- a/frontend/src/styles/admin.css
+++ b/frontend/src/styles/admin.css
@@ -116,9 +116,8 @@
.server-row.dragging { opacity: .45; background: color-mix(in srgb, var(--primary) 8%, transparent); }
.drag-handle { display: inline-flex; width: 18px; height: 32px; align-items: center; justify-content: center; padding: 0; color: var(--admin-muted); cursor: grab; touch-action: none; }
.drag-handle:active { cursor: grabbing; }
-.server-name { display: flex; min-width: 0; flex-direction: column; }
+.server-name { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.server-row strong { overflow: hidden; color: var(--admin-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
-.server-row small { color: var(--admin-muted); font-size: 12px; }
.server-row .row-actions { margin-left: 3px; }
.server-row .icon-btn { width: 32px; height: 32px; }
.list-empty { padding: 72px 0; color: var(--admin-muted); font-size: 13px; text-align: center; }
@@ -311,7 +310,13 @@
.server-name-main { display: flex; min-width: 0; align-items: center; gap: 7px; }
.server-name-main .region-flag { flex: 0 0 auto; font-size: 16px; line-height: 1; }
.server-name-main strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
-.server-name-main small { overflow: hidden; color: var(--admin-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.server-name-meta { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--admin-muted); font-size: 12px; }
+.ip-badge { flex: 0 0 auto; padding: 0 6px; border: 1px solid var(--admin-border); border-radius: 999px; font-size: 10px; font-weight: 600; line-height: 1.5; letter-spacing: .03em; }
+.ip-badge.v6 { color: var(--admin-accent); border-color: color-mix(in srgb, var(--admin-accent) 40%, transparent); }
+.ip-value { overflow: hidden; min-width: 0; max-width: 30ch; padding: 0; border: 0; background: none; color: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: copy; }
+.ip-value:hover { color: var(--admin-text); text-decoration: underline; text-underline-offset: 3px; }
+.meta-dot { flex: 0 0 auto; opacity: .55; }
+.meta-item { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.about-page { display: flex; flex-direction: column; gap: 20px; }
.about-brand { display: flex; align-items: center; gap: 14px; }
.about-brand img { flex: 0 0 auto; }
diff --git a/frontend/src/styles/dashboard.css b/frontend/src/styles/dashboard.css
index a1fa8c0..fe92e1b 100644
--- a/frontend/src/styles/dashboard.css
+++ b/frontend/src/styles/dashboard.css
@@ -43,9 +43,9 @@
.group-tabs button.active { color: var(--foreground); background: var(--card-solid); box-shadow: 0 1px 4px rgb(20 35 42 / .08); }
.result-count { margin-left: auto; color: var(--muted-foreground); font-size: 11px; white-space: nowrap; }
-.node-grid, .loading-grid { display: grid; align-items: start; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
+.node-grid { display: grid; align-items: start; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
.node-card { position: relative; display: flex; width: 100%; min-width: 0; min-height: var(--node-card-min-height); align-self: start; flex-direction: column; overflow: hidden; border-radius: 12px; padding: 0; text-align: left; box-shadow: var(--node-card-shadow); transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; }
-.node-grid.carrier-latency .node-card, .loading-grid.carrier-latency span { min-height: var(--node-card-carrier-min-height); }
+.node-grid.carrier-latency .node-card { min-height: var(--node-card-carrier-min-height); }
.node-card:hover { z-index: 2; border-color: color-mix(in srgb, var(--foreground) 24%, var(--node-card-border)); background: var(--node-card-hover); box-shadow: 0 12px 38px rgb(15 23 42 / .2); }
.dark .node-card:hover { box-shadow: 0 12px 38px rgb(0 0 0 / .54); }
.node-card.offline { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger-bar) 30%, transparent), var(--node-card-shadow); }
@@ -95,9 +95,9 @@
.carrier-row .quality-bars { min-height: 8px; }
.empty-inline { display: grid; min-height: 0; flex: 1; place-items: center; color: var(--muted-foreground); font-size: 11px; }
.quality-panel b { color: var(--foreground); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; }
-.quality-bars { display: grid; min-height: 0; flex: 1; align-items: end; gap: 1px; opacity: .85; }
+.quality-bars { display: grid; min-height: 0; flex: 1; align-items: end; gap: 1px; opacity: .85; transition: opacity 150ms ease; }
.quality-bars > span { position: relative; display: flex; height: 100%; min-width: 0; align-items: end; overflow: visible; border-radius: 1px; background: color-mix(in srgb, var(--foreground) 6%, transparent); }
-.quality-bars i { display: block; width: 100%; height: 100%; min-height: 3px; border-radius: 1px; }
+.quality-bars i { display: block; width: 100%; height: 100%; min-height: 3px; border-radius: 1px; transform-origin: bottom; transition: transform 150ms ease, opacity 150ms ease; }
.quality-bars i.good { background: var(--success-bar); }
.quality-bars i.fair { background: #a3e635; }
.quality-bars i.warning { background: #eab308; }
@@ -118,6 +118,19 @@
.node-card:focus-visible .quality-bar:first-child .quality-tooltip.center,
.node-card:focus-visible .quality-bar:last-child .quality-tooltip.center { transform: translate(-50%, 0); }
+/* 悬停面板时压暗其余柱、放大悬停柱,与 Komari 原版一致。 */
+.quality-panel:hover .quality-bars { opacity: 1; }
+.quality-panel:hover .quality-bars i { opacity: .6; }
+.quality-panel:hover .quality-bar:hover i { opacity: 1; transform: scaleY(1.6); }
+
+.node-offline-overlay { position: absolute; z-index: 20; display: flex; inset: 0; flex-direction: column; align-items: center; justify-content: center; background: rgb(255 255 255 / .2); }
+.dark .node-offline-overlay { background: rgb(0 0 0 / .2); }
+html[data-blur="on"] .node-offline-overlay { backdrop-filter: blur(2px); }
+.node-offline-overlay span { color: var(--danger); font-size: 14px; font-weight: 600; }
+.node-offline-overlay small { margin-top: 4px; color: var(--muted-foreground); font-size: 11px; }
+/* 离线卡的柱状面板再压一层,避免细小的色柱在遮罩下仍显得“活着”。 */
+.node-card.offline .quality-panel { opacity: .5; filter: blur(1px); }
+
.error-band { display: flex; align-items: center; justify-content: space-between; border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); border-radius: 7px; padding: 12px 14px; color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, transparent); font-size: 12px; }
.error-band button { font-weight: 700; }
.empty-state, .chart-loading { display: flex; min-height: 340px; align-items: center; justify-content: center; flex-direction: column; gap: 16px; color: var(--muted-foreground); font-size: 13px; }
@@ -136,8 +149,10 @@
.dashboard-login-turnstile .turnstile-widget { width: 100%; }
.dashboard-login-submit { width: 100%; min-height: 42px; }
.verification-status { color: var(--primary); font-size: 11px; }
-.loading-grid span { height: var(--node-card-min-height); border: 1px solid var(--border); border-radius: 12px; background: var(--card); animation: loading-pulse 1.3s ease-in-out infinite alternate; }
-@keyframes loading-pulse { to { opacity: .46; } }
+/* 首屏加载:居中旋转环 + 提示文案,与 Komari 原版的 Loading 一致。 */
+.dashboard-loading { display: flex; min-height: 40vh; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--muted-foreground); font-size: 13px; }
+.loading-ring { width: 36px; height: 36px; flex: 0 0 auto; border: 2px solid color-mix(in srgb, var(--primary) 20%, transparent); border-top-color: var(--primary); border-radius: 50%; animation: loading-spin 1s linear infinite; }
+@keyframes loading-spin { to { transform: rotate(360deg); } }
.detail-page { display: flex; flex-direction: column; gap: 14px; }
.detail-hero { display: flex; min-width: 0; align-items: center; gap: 12px; border-radius: 8px; padding: 14px 20px; }
@@ -217,7 +232,7 @@
.toolbar { flex-wrap: wrap; gap: 8px; }
.search-box { width: 100%; }
.group-tabs { max-width: calc(100% - 58px); }
- .node-grid, .loading-grid { grid-template-columns: 1fr; }
+ .node-grid { grid-template-columns: 1fr; }
.detail-hero { padding: 14px 16px; }
.detail-hero > .icon-btn { margin-left: -4px; }
.detail-flag { font-size: 19px; }
diff --git a/src/lib.rs b/src/lib.rs
index bc6462e..c511dcd 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -676,8 +676,8 @@ pub(crate) fn validate_server(input: &ServerInput) -> Option<&'static str> {
if input.tags.chars().count() > 240 {
return Some("标签字段过长");
}
- if input.traffic_limit < 0 {
- return Some("流量限额不能为负数");
+ if input.traffic_limit < -1 {
+ return Some("流量限额不能小于 -1(-1 表示不限)");
}
if !matches!(
input.traffic_limit_type.as_str(),
@@ -688,8 +688,8 @@ pub(crate) fn validate_server(input: &ServerInput) -> Option<&'static str> {
if !valid_server_price(input.price) {
return Some("价格无效");
}
- if !(1..=3650).contains(&input.billing_cycle) {
- return Some("计费周期应为 1 至 3650 天");
+ if !(0..=3650).contains(&input.billing_cycle) {
+ return Some("计费周期应为 0(一次性)或 1 至 3650 天");
}
if input.currency.len() != 3
|| !input