From 6b4ee11cbf15afa65ee797239bbf95f271b93edc Mon Sep 17 00:00:00 2001 From: imengying Date: Sun, 30 Aug 2026 09:59:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 2 +- frontend/src/components/AdminPanel.tsx | 114 ++++++++++++++++++++---- frontend/src/components/NodeCard.tsx | 19 ++-- frontend/src/components/NodeDetails.tsx | 42 ++++----- frontend/src/format.test.ts | 41 +++++++++ frontend/src/format.ts | 41 ++++++--- frontend/src/hooks/useNodeLatency.ts | 5 +- frontend/src/styles/admin.css | 11 ++- frontend/src/styles/dashboard.css | 29 ++++-- src/lib.rs | 8 +- 10 files changed, 235 insertions(+), 77 deletions(-) create mode 100644 frontend/src/format.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 444451e..52806ea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -114,7 +114,7 @@ function HomeView() { : null} {error ?
{error}
: null} {loading && !servers.length ? ( -
{Array.from({ length: 8 }).map((_, index) => )}
+
) : visible.length ? (
{visible.map((server) => openServer(server)} />)} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index a81ebd8..2596506 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -30,9 +30,9 @@ import { Check, ExternalLink, } from "lucide-react"; -import { DragEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError, getToken, setToken } from "../api"; -import { isOnline } from "../format"; +import { formatByteSize, isOnline, parseByteSize } from "../format"; import { derivePassword } from "../password"; import { ASSET_CURRENCIES, type AdminServer, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue } from "../types"; import { Checkbox } from "./Checkbox"; @@ -119,6 +119,46 @@ function toInput(server: AdminServer): ServerInput { }; } +// 计费周期下拉预设;存量节点里的自定义天数会以「N 天」选项追加在后面。 +const BILLING_CYCLES: Array<{ days: number; label: string }> = [ + { days: 30, label: "月" }, + { days: 90, label: "季" }, + { days: 180, label: "半年" }, + { days: 365, label: "年" }, + { days: 0, label: "一次性" }, +]; + +// 流量限额输入:"-1" 表示不限,其余按字节单位解析。 +function parseTrafficLimit(raw: string): number | null { + return raw.trim() === "-1" ? -1 : parseByteSize(raw); +} + +// last_ip 来自 CF-Connecting-IP,是裸 IP;v6 带冒号,v4 是四段数字。 +function ipFamily(ip: string): "v4" | "v6" | null { + const value = ip.trim(); + if (!value) return null; + if (value.includes(":")) return "v6"; + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) ? "v4" : null; +} + +function ServerIpMeta({ ip, agentVersion, onCopy }: { ip: string; agentVersion: string | null; onCopy: (ip: string) => void }) { + const family = ipFamily(ip); + return ( +
+ {family ? ( + <> + {family === "v6" ? "IPv6" : "IPv4"} + + + ) : ( + {ip || "尚未上报 IP"} + )} + · + {agentVersion ? `Agent v${agentVersion}` : "Agent 未上报版本"} +
+ ); +} + function formatDate(value: number | null) { return value ? new Date(value * 1000).toISOString().slice(0, 10) : ""; } @@ -172,8 +212,12 @@ export function AdminPanel({ const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyServer); const [install, setInstall] = useState(null); - const [rxCurrentGb, setRxCurrentGb] = useState("0"); - const [txCurrentGb, setTxCurrentGb] = useState("0"); + const [priceText, setPriceText] = useState("0"); + const [trafficLimitText, setTrafficLimitText] = useState("0"); + const [rxCurrentText, setRxCurrentText] = useState("0"); + const [txCurrentText, setTxCurrentText] = useState("0"); + const [rxCurrentBytes, setRxCurrentBytes] = useState(0); + const [txCurrentBytes, setTxCurrentBytes] = useState(0); const [rxBaseBytes, setRxBaseBytes] = useState(0); const [txBaseBytes, setTxBaseBytes] = useState(0); const [installPlatform, setInstallPlatform] = useState("linux"); @@ -247,24 +291,51 @@ export function AdminPanel({ function openEditor(server?: AdminServer) { setEditing(server ?? "new"); setForm(server ? toInput(server) : { ...emptyServer }); + setPriceText(server ? String(server.price) : "0"); + setTrafficLimitText(formatByteSize(server?.traffic_limit ?? 0)); // 修正值输入展示“当前累计用量”(已上报 + 已修正),保存时换算回差值。 - const rxBase = server ? (server.net_rx_total ?? 0) - server.rx_correction : 0; - const txBase = server ? (server.net_tx_total ?? 0) - server.tx_correction : 0; - setRxCurrentGb(((server ? (server.net_rx_total ?? 0) : 0) / 1024 ** 3).toFixed(2)); - setTxCurrentGb(((server ? (server.net_tx_total ?? 0) : 0) / 1024 ** 3).toFixed(2)); - setRxBaseBytes(rxBase); - setTxBaseBytes(txBase); + const rxCurrent = server?.net_rx_total ?? 0; + const txCurrent = server?.net_tx_total ?? 0; + setRxCurrentText(formatByteSize(rxCurrent)); + setTxCurrentText(formatByteSize(txCurrent)); + setRxCurrentBytes(rxCurrent); + setTxCurrentBytes(txCurrent); + setRxBaseBytes(rxCurrent - (server?.rx_correction ?? 0)); + setTxBaseBytes(txCurrent - (server?.tx_correction ?? 0)); setError(""); } + // 价格框放行 "-"、"1." 这类数字骨架中间态,能解析出数字才提交到表单。 + function updatePriceText(raw: string) { + if (!/^-?\d*\.?\d*$/.test(raw)) return; + setPriceText(raw); + const parsed = Number(raw); + if (Number.isFinite(parsed)) updateForm("price", parsed); + } + + // 带单位输入(如 100 G / 0.5 T):解析不了的中间态留在框里,失焦回显最后一次有效值。 + function sizeInputProps(text: string, setText: (value: string) => void, commit: (bytes: number) => void, current: number, parse: (raw: string) => number | null = parseByteSize) { + return { + value: text, + inputMode: "decimal" as const, + onChange: (event: ChangeEvent) => { + const raw = event.target.value; + setText(raw); + const parsed = parse(raw); + if (parsed !== null) commit(parsed); + }, + onBlur: () => setText(formatByteSize(current)), + }; + } + async function saveServer(event: FormEvent) { event.preventDefault(); setBusy(true); setError(""); const payload: ServerInput = { ...form, - rx_correction: Math.round(((Number(rxCurrentGb) || 0) * 1024 ** 3) - rxBaseBytes), - tx_correction: Math.round(((Number(txCurrentGb) || 0) * 1024 ** 3) - txBaseBytes), + rx_correction: Math.round(rxCurrentBytes - rxBaseBytes), + tx_correction: Math.round(txCurrentBytes - txBaseBytes), }; try { if (editing === "new") { @@ -320,6 +391,15 @@ export function AdminPanel({ } } + async function copyServerIp(ip: string) { + try { + await navigator.clipboard.writeText(ip); + setNotice("IP 已复制"); + } catch { + setError("复制失败,请手动选择复制"); + } + } + async function move(index: number, offset: number) { const target = index + offset; if (target < 0 || target >= servers.length) return; @@ -578,7 +658,7 @@ export function AdminPanel({ toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} /> -
{server.name}{server.last_ip || "尚未上报 IP"} · {server.agent_version ? `Agent v${server.agent_version}` : "Agent 未上报版本"}
+
{server.name}
void copyServerIp(value)} />
))} @@ -655,7 +735,7 @@ export function AdminPanel({
{database ? <> :

正在读取数据库统计...

}
每日汇率

{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || "等待首次更新"}${exchangeRates.stale ? " · 数据待更新" : ""}` : "正在读取 D1 汇率快照"}

- {exchangeRates ?
{ASSET_CURRENCIES.map((currency) => )}
币种1 CNY 可兑换
{currency}{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}
:
尚未读取
} + {exchangeRates ?
{ASSET_CURRENCIES.filter((currency) => currency !== "CNY").map((currency) => )}
币种1 CNY 可兑换
{currency}{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}
:
尚未读取
}
Cloudflare 用量

统计周期使用 UTC

@@ -686,10 +766,10 @@ export function AdminPanel({ {editing ?
节点配置

{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}

-
-
+
+
-
+
updateForm("auto_renewal", value)} /> updateForm("auto_update", value)} /> updateForm("hidden", value)} /> updateForm("offline_notify_disabled", value)} />
: null} diff --git a/frontend/src/components/NodeCard.tsx b/frontend/src/components/NodeCard.tsx index 2837b80..f53a04d 100644 --- a/frontend/src/components/NodeCard.tsx +++ b/frontend/src/components/NodeCard.tsx @@ -16,7 +16,6 @@ import { isOnline, number, percent, - timeAgo, trafficUsed, } from "../format"; import type { Config, Server } from "../types"; @@ -114,6 +113,7 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C const carrierEmpty = quality.loading ? ui(locale, "加载中", "Loading") : ui(locale, "未匹配到线路", "No lines matched"); + const lastUpdated = new Date(number(server.timestamp) * 1000).toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }); // 名字后面带上在线状态:卡片是 ); 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