优化UI
This commit is contained in:
1 parent
75d4f9d913
commit
6b4ee11cbf
10 files changed
+235
-77
No files matched your search
@@ -114,7 +114,7 @@ function HomeView() {
|
||||
</div> : null}
|
||||
{error ? <div className="error-band"><span>{error}</span><button onClick={() => void reload()}>{ui(locale, "重试", "Retry")}</button></div> : null}
|
||||
{loading && !servers.length ? (
|
||||
<div className={`loading-grid ${carrierLatency ? "carrier-latency" : ""}`}>{Array.from({ length: 8 }).map((_, index) => <span key={index} />)}</div>
|
||||
<div className="dashboard-loading"><span className="loading-ring" aria-hidden="true" /><p>{ui(locale, "加载中…", "Loading…")}</p></div>
|
||||
) : visible.length ? (
|
||||
<section className={`node-grid ${carrierLatency ? "carrier-latency" : ""}`}>
|
||||
{visible.map((server) => <NodeCard key={server.id} server={server} config={config} onOpen={() => openServer(server)} />)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="server-name-meta">
|
||||
{family ? (
|
||||
<>
|
||||
<span className={`ip-badge ${family}`}>{family === "v6" ? "IPv6" : "IPv4"}</span>
|
||||
<button type="button" className="ip-value" title={`点击复制:${ip}`} onClick={() => onCopy(ip)}>{ip}</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="meta-item">{ip || "尚未上报 IP"}</span>
|
||||
)}
|
||||
<span className="meta-dot">·</span>
|
||||
<span className="meta-item">{agentVersion ? `Agent v${agentVersion}` : "Agent 未上报版本"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<AdminServer | "new" | null>(null);
|
||||
const [form, setForm] = useState<ServerInput>(emptyServer);
|
||||
const [install, setInstall] = useState<AgentInstallInfo | null>(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<AgentPlatform>("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<HTMLInputElement>) => {
|
||||
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({
|
||||
<button type="button" className="drag-handle" draggable onDragStart={(event) => startDrag(event, server.id)} onDragEnd={() => setDraggingId("")} title={`拖动排序:${server.name}`}><GripVertical size={15} /></button>
|
||||
<Checkbox checked={selectedIds.includes(server.id)} onChange={() => toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} />
|
||||
<span className={`status-dot ${settings && isOnline(server, settings.offline_threshold_seconds) ? "online" : ""}`} />
|
||||
<div className="server-name"><div className="server-name-main"><Flag region={server.region} size={17} /><strong>{server.name}</strong><small>{server.last_ip || "尚未上报 IP"} · {server.agent_version ? `Agent v${server.agent_version}` : "Agent 未上报版本"}</small></div></div>
|
||||
<div className="server-name"><div className="server-name-main"><Flag region={server.region} size={17} /><strong>{server.name}</strong></div><ServerIpMeta ip={server.last_ip} agentVersion={server.agent_version} onCopy={(value) => void copyServerIp(value)} /></div>
|
||||
<div className="row-actions"><button className="icon-btn" disabled={index === 0} onClick={() => void move(index, -1)} title="上移"><ChevronUp size={15} /></button><button className="icon-btn" disabled={index === servers.length - 1} onClick={() => void move(index, 1)} title="下移"><ChevronDown size={15} /></button><button className="icon-btn" disabled={busy} onClick={() => void showInstallCommand(server)} title="显示安装命令"><Download size={15} /></button><button className="icon-btn" onClick={() => openEditor(server)} title="编辑节点"><Pencil size={15} /></button><button className="icon-btn danger" onClick={() => void remove(server)} title="删除节点"><Trash2 size={15} /></button></div>
|
||||
</div>
|
||||
))}
|
||||
@@ -655,7 +735,7 @@ export function AdminPanel({
|
||||
<div className="data-stat-grid">{database ? <><DataStat label="节点" value={database.server_count} /><DataStat label="在线" value={database.online_count} /><DataStat label="历史行数" value={database.history_rows.toLocaleString()} /></> : <p className="settings-hint">正在读取数据库统计...</p>}</div>
|
||||
<div className="usage-section">
|
||||
<div className="usage-head"><div><div className="section-title"><Coins size={15} />每日汇率</div><p className="settings-hint">{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || "等待首次更新"}${exchangeRates.stale ? " · 数据待更新" : ""}` : "正在读取 D1 汇率快照"}</p></div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void refreshExchangeRates()}><RotateCw size={15} />立即更新</button></div>
|
||||
{exchangeRates ? <div className="usage-table-wrap"><table className="usage-table"><thead><tr><th>币种</th><th>1 CNY 可兑换</th></tr></thead><tbody>{ASSET_CURRENCIES.map((currency) => <tr key={currency}><th scope="row">{currency}</th><td>{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}</td></tr>)}</tbody></table></div> : <div className="usage-empty">尚未读取</div>}
|
||||
{exchangeRates ? <div className="usage-table-wrap"><table className="usage-table"><thead><tr><th>币种</th><th>1 CNY 可兑换</th></tr></thead><tbody>{ASSET_CURRENCIES.filter((currency) => currency !== "CNY").map((currency) => <tr key={currency}><th scope="row">{currency}</th><td>{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}</td></tr>)}</tbody></table></div> : <div className="usage-empty">尚未读取</div>}
|
||||
</div>
|
||||
<div className="usage-section">
|
||||
<div className="usage-head"><div><div className="section-title"><Cloud size={15} />Cloudflare 用量</div><p className="settings-hint">统计周期使用 UTC</p></div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void loadCloudflareUsage()}><RotateCw size={15} />{cloudflareUsage ? "刷新用量" : "查询用量"}</button></div>
|
||||
@@ -686,10 +766,10 @@ export function AdminPanel({
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={serverDialog.onBackdropMouseDown}><form ref={serverDialog.dialogRef} className="editor-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="server-editor-title" tabIndex={-1} onSubmit={saveServer}>
|
||||
<header><div><span className="eyebrow">节点配置</span><h3 id="server-editor-title">{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}</h3></div></header>
|
||||
<div className="form-grid"><label><span>名称</span><input autoFocus required value={form.name} onChange={(event) => updateForm("name", event.target.value)} /></label><label><span>地区代码</span><input maxLength={16} placeholder="CN / JP / DE" value={form.region} onChange={(event) => updateForm("region", event.target.value.toUpperCase())} /></label><label><span>分组</span><input value={form.group_name} onChange={(event) => updateForm("group_name", event.target.value)} /></label><label><span>标签</span><input placeholder="主力, 线路:BGP" value={form.tags} onChange={(event) => updateForm("tags", event.target.value)} /></label></div>
|
||||
<div className="form-grid three"><label><span>流量限额(GB)</span><input min="0" type="number" value={Math.round(form.traffic_limit / 1024 ** 3)} onChange={(event) => updateForm("traffic_limit", Number(event.target.value) * 1024 ** 3)} /></label><label><span>流量口径</span><select value={form.traffic_limit_type} onChange={(event) => updateForm("traffic_limit_type", event.target.value as ServerInput["traffic_limit_type"])}><option value="sum">上下行合计</option><option value="max">取较大值</option><option value="min">取较小值</option><option value="up">仅上行</option><option value="down">仅下行</option></select></label><label><span>流量重置日</span><input min="1" max="31" type="number" value={form.reset_day} onChange={(event) => updateForm("reset_day", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>价格(0 隐藏,-1 免费)</span><input min="-1" step="0.01" type="number" value={form.price} onChange={(event) => updateForm("price", Number(event.target.value))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期(天)</span><input min="1" max="3650" type="number" value={form.billing_cycle} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>流量限额(-1 不限)</span><input placeholder="如 100 G,不带单位按 GB" {...sizeInputProps(trafficLimitText, setTrafficLimitText, (bytes) => updateForm("traffic_limit", bytes), form.traffic_limit, parseTrafficLimit)} /></label><label><span>流量口径</span><select value={form.traffic_limit_type} onChange={(event) => updateForm("traffic_limit_type", event.target.value as ServerInput["traffic_limit_type"])}><option value="sum">上下行合计</option><option value="max">取较大值</option><option value="min">取较小值</option><option value="up">仅上行</option><option value="down">仅下行</option></select></label><label><span>流量重置日</span><input min="1" max="31" type="number" value={form.reset_day} onChange={(event) => updateForm("reset_day", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>价格(0 隐藏,-1 免费)</span><input inputMode="decimal" value={priceText} onChange={(event) => updatePriceText(event.target.value)} onBlur={() => setPriceText(String(form.price))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期</span><select value={String(form.billing_cycle)} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))}>{BILLING_CYCLES.map((cycle) => <option key={cycle.days} value={cycle.days}>{cycle.label}</option>)}{BILLING_CYCLES.every((cycle) => cycle.days !== form.billing_cycle) ? <option value={form.billing_cycle}>{form.billing_cycle} 天</option> : null}</select></label></div>
|
||||
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>Agent 上报间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>指标采样间隔(秒)</span><input min="1" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>下行流量当前值(GB)</span><input min="0" step="0.1" type="number" value={rxCurrentGb} onChange={(event) => setRxCurrentGb(event.target.value)} /></label><label><span>上行流量当前值(GB)</span><input min="0" step="0.1" type="number" value={txCurrentGb} onChange={(event) => setTxCurrentGb(event.target.value)} /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></label></div>
|
||||
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>下行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(rxCurrentText, setRxCurrentText, setRxCurrentBytes, rxCurrentBytes)} /></label><label><span>上行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(txCurrentText, setTxCurrentText, setTxCurrentBytes, txCurrentBytes)} /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></label></div>
|
||||
<div className="settings-toggles editor-toggles"><Toggle label="自动续费" checked={form.auto_renewal} onChange={(value) => updateForm("auto_renewal", value)} /><Toggle label="Agent 自动更新" checked={form.auto_update} onChange={(value) => updateForm("auto_update", value)} /><Toggle label="隐藏节点" checked={form.hidden} onChange={(value) => updateForm("hidden", value)} /><Toggle label="关闭离线告警" checked={form.offline_notify_disabled} onChange={(value) => updateForm("offline_notify_disabled", value)} /></div>
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={15} />保存节点</button></div>
|
||||
</form></div> : null}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
// 名字后面带上在线状态:卡片是 <button>,子树被裁掉,这个 aria-label 是读屏唯一能拿到的字符串。
|
||||
// 状态在视觉上只由 status-dot 的颜色表达(WCAG 1.4.1 靠颜色传达信息),这里补成文字。
|
||||
@@ -129,9 +129,7 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
|
||||
<div className="node-body">
|
||||
<div className="node-chips">
|
||||
{config.show_uptime ? <span>{online
|
||||
? ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)
|
||||
: ui(locale, `离线 · ${timeAgo(server.timestamp, locale)}`, `Offline · ${timeAgo(server.timestamp, locale)}`)}</span> : null}
|
||||
{config.show_uptime ? <span>{ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)}</span> : null}
|
||||
{config.show_price && price ? <span title={price}>{price}</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -147,15 +145,15 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
里面加什么都读不到,所以移除而不是修补。读屏用户拿不到这些数值,真正的出路是
|
||||
把卡片从 <button> 改成内容 + 覆盖层按钮,会动 CSS 和焦点行为,暂未做。 */}
|
||||
<div className={`data-grid ${showExpiryPanel ? "" : "two-columns"}`}>
|
||||
<div className="data-panel">
|
||||
<div className="data-panel" title={ui(locale, "实时速率", "Live speed")}>
|
||||
<CompactLine icon={<ChevronUp size={11} />} tone="success-text">{formatSpeed(server.net_out)}</CompactLine>
|
||||
<CompactLine icon={<ChevronDown size={11} />} tone="info-text">{formatSpeed(server.net_in)}</CompactLine>
|
||||
</div>
|
||||
<div className="data-panel">
|
||||
<div className="data-panel" title={ui(locale, "累计流量", "Total traffic")}>
|
||||
<CompactLine icon={<Upload size={11} />}>{formatBytes(server.net_tx_total)}</CompactLine>
|
||||
<CompactLine icon={<Download size={11} />}>{formatBytes(server.net_rx_total)}</CompactLine>
|
||||
</div>
|
||||
{showExpiryPanel ? <div className="data-panel">
|
||||
{showExpiryPanel ? <div className="data-panel" title={ui(locale, "剩余周期", "Billing cycle")}>
|
||||
{config.show_expiry ? <CompactLine icon={<CalendarDays size={11} />}>{formatExpire(server, locale)}</CompactLine> : null}
|
||||
{config.show_price ? <CompactLine icon={<Coins size={11} />}>{server.price === -1
|
||||
? ui(locale, "免费", "Free")
|
||||
@@ -172,6 +170,13 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
<QualityPanel label={ui(locale, "丢包", "Packet loss")} value={quality.lossDisplay} bars={quality.lossBars} />
|
||||
</>}
|
||||
</div> : null}
|
||||
|
||||
{!online ? <div className="node-offline-overlay" aria-hidden="true">
|
||||
<span>{ui(locale, "离线", "Offline")}</span>
|
||||
<small>{server.timestamp
|
||||
? ui(locale, `最后更新 ${lastUpdated}`, `Last update ${lastUpdated}`)
|
||||
: ui(locale, "暂无更新时间", "No update yet")}</small>
|
||||
</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -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 ? <div className="chart-loading">{ui(locale, "正在读取历史数据", "Loading history")}</div> : chartType === "latency" && latencyLoading ? <div className="chart-loading">{ui(locale, "正在读取延迟数据", "Loading latency")}</div> : chartType === "load" && !data.length ? <div className="chart-loading">{ui(locale, "暂无历史数据", "No history")}</div> : chartType === "load" ? (
|
||||
<div className="detail-charts-grid">
|
||||
<ChartCard title="CPU" value={`${number(last.cpu).toFixed(1)}%`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={30} /><YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={36} /><Tooltip contentStyle={tooltipStyle} formatter={(value) => [`${Number(value).toFixed(1)}%`, "CPU"]} /><Area type="monotone" dataKey="cpu" stroke="var(--danger-bar)" fill="var(--danger-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : undefined} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={36} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [`${Number(value).toFixed(1)}%`, "CPU"]} /><Area type="monotone" dataKey="cpu" stroke="var(--danger-bar)" fill="var(--danger-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "内存", "Memory")} value={`${formatBytes(last.mem_used)} / ${formatBytes(last.mem_total)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={30} /><YAxis domain={[0, last.mem_total]} ticks={resourceTicks(last.mem_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} formatter={(value) => [formatBytes(Number(value)), "内存"]} /><Area type="monotone" dataKey="mem_used" stroke="var(--primary)" fill="var(--primary)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : undefined} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.mem_total]} ticks={resourceTicks(last.mem_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "内存"]} /><Area type="monotone" dataKey="mem_used" stroke="var(--primary)" fill="var(--primary)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "硬盘", "Disk")} value={`${formatBytes(last.disk_used)} / ${formatBytes(last.disk_total)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={30} /><YAxis domain={[0, last.disk_total]} ticks={resourceTicks(last.disk_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} formatter={(value) => [formatBytes(Number(value)), "硬盘"]} /><Area type="monotone" dataKey="disk_used" stroke="var(--warning-bar)" fill="var(--warning-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : undefined} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.disk_total]} ticks={resourceTicks(last.disk_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "硬盘"]} /><Area type="monotone" dataKey="disk_used" stroke="var(--warning-bar)" fill="var(--warning-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "网络", "Network")} value={`↑ ${formatSpeed(last.net_out)} ↓ ${formatSpeed(last.net_in)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={30} /><YAxis tick={{ fontSize: 10 }} width={48} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /><Area type="monotone" dataKey="net_out" stroke="var(--success-bar)" fill="var(--success-bar)" fillOpacity={0.12} strokeWidth={2} dot={false} isAnimationActive={false} /><Area type="monotone" dataKey="net_in" stroke="var(--info)" fill="var(--info)" fillOpacity={0.1} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={loadTimeRange ? loadTimeRange.domain : undefined} ticks={loadTimeRange?.ticks} allowDataOverflow={loadHours > 0} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis tick={{ fontSize: 10 }} width={48} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /><Area type="monotone" dataKey="net_out" stroke="var(--success-bar)" fill="var(--success-bar)" fillOpacity={0.12} strokeWidth={2} dot={false} isAnimationActive={false} /><Area type="monotone" dataKey="net_in" stroke="var(--info)" fill="var(--info)" fillOpacity={0.1} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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 / 年");
|
||||
});
|
||||
});
|
||||
+31
-10
@@ -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<string, number> = {
|
||||
"": 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<Server, "timestamp">, 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<Server, "price" | "billing_cycle" | "cu
|
||||
if (server.price === -1) return ui(locale, "免费", "Free");
|
||||
if (server.price === 0) return "";
|
||||
if (server.price < 0) return "";
|
||||
const cycle = server.billing_cycle >= 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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
Reference in new issue
Block a user