Files
NodeFlare/frontend/src/components/AdminPanel.tsx
T

1340 lines
87 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AlertTriangle,
Info,
ArrowLeft,
ArrowRightLeft,
ChevronDown,
ChevronUp,
CircleAlert,
CircleCheck,
Coins,
Copy,
Database,
Download,
Eye,
GripVertical,
KeyRound,
LogOut,
MonitorSmartphone,
Moon,
Palette,
Pencil,
Plus,
Power,
RadioTower,
RotateCw,
Save,
Search,
ServerCog,
ShieldCheck,
SlidersHorizontal,
Sun,
Terminal,
Trash2,
Upload,
Check,
ExternalLink,
} from "lucide-react";
import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError } from "../api";
import { adminTabFromPath, adminTabPaths, canonicalAdminPath, type AdminTab } from "../adminRoutes";
import { formatBytes, formatByteSize, isOnline, parseByteSize } from "../format";
import { derivePassword } from "../password";
import { hasActiveRemoteTasks, isRemoteTaskActive, REMOTE_TASK_POLL_INTERVAL_MS } from "../refresh";
import { ASSET_CURRENCIES, type AdminServer, type Config, type DatabaseMigrationResult, type DatabaseStats, type ExchangeRates, type LoginSession, type RemoteTask, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types";
import { Checkbox } from "./Checkbox";
import { TurnstileWidget } from "./TurnstileWidget";
import { useDialog } from "./useDialog";
import { SiteLogo } from "./SiteLogo";
import { LatencyManager } from "./LatencyManager";
import { AlertRuleManager } from "./AlertRuleManager";
import { TelegramSettings } from "./TelegramSettings";
import { Flag } from "./Flag";
import pkg from "../../package.json";
const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version;
type AgentPlatform = "linux" | "windows" | "macos" | "freebsd";
type ThemeSourceMode = "repository" | "upload";
interface AgentInstallInfo {
agent_token: string;
agent_mirror: string;
}
const adminPages: Record<AdminTab, { title: string; description: string }> = {
servers: { title: "服务器", description: "管理监控节点和运行参数" },
latency: { title: "延迟检测", description: "配置分配给各服务器的 TCP 与 ICMP 延迟任务" },
appearance: { title: "站点设置", description: "调整站点信息、公开内容和前台显示项目" },
themes: { title: "主题商店", description: "选择内置主题或安装本地主题包" },
themeSettings: { title: "主题设置", description: "调整当前前端主题提供的显示选项" },
alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" },
security: { title: "登录与安全", description: "管理管理员账号、登录设备和安全验证" },
data: { title: "数据库", description: "查看空间、备份恢复和迁移数据库" },
remote: { title: "远程执行", description: "输入命令并执行" },
about: { title: "关于", description: "版本信息与项目地址" },
};
const adminNavigation = [
{ tab: "servers" as const, label: "服务器", icon: ServerCog },
{ tab: "latency" as const, label: "延迟检测", icon: RadioTower },
{ tab: "remote" as const, label: "远程执行", icon: Terminal },
{ tab: "alerts" as const, label: "通知", icon: AlertTriangle },
{ tab: "themes" as const, label: "主题商店", icon: Palette },
{ tab: "themeSettings" as const, label: "主题设置", icon: SlidersHorizontal },
{ tab: "appearance" as const, label: "站点设置", icon: Eye },
{ tab: "security" as const, label: "登录与安全", icon: ShieldCheck },
{ tab: "data" as const, label: "数据库", icon: Database },
{ tab: "about" as const, label: "关于", icon: Info },
];
const remoteTaskStatusLabels: Record<RemoteTask["status"], string> = {
pending: "等待接收",
sent: "执行中",
success: "执行成功",
failed: "执行失败",
};
const emptyServer: ServerInput = {
name: "",
region: "",
group_name: "默认",
tags: "",
hidden: false,
expires_at: null,
traffic_limit: 0,
traffic_limit_type: "sum",
price: 0,
billing_cycle: 30,
currency: "CNY",
auto_renewal: false,
network_interface: "",
reset_day: 1,
report_interval: 60,
collect_interval: 1,
rx_correction: 0,
tx_correction: 0,
agent_mirror: "",
offline_notify_disabled: false,
auto_update: true,
};
function toInput(server: AdminServer): ServerInput {
return {
...emptyServer,
name: server.name,
region: server.region,
group_name: server.group_name,
tags: server.tags,
hidden: server.hidden,
expires_at: server.expires_at,
traffic_limit: server.traffic_limit,
traffic_limit_type: server.traffic_limit_type,
price: server.price,
billing_cycle: server.billing_cycle,
currency: server.currency,
auto_renewal: server.auto_renewal,
network_interface: server.network_interface,
reset_day: server.reset_day,
report_interval: server.report_interval,
collect_interval: server.collect_interval,
rx_correction: server.rx_correction,
tx_correction: server.tx_correction,
agent_mirror: server.agent_mirror,
offline_notify_disabled: server.offline_notify_disabled,
auto_update: server.auto_update,
};
}
const BILLING_CYCLES: Array<{ days: number; label: string }> = [
{ days: 30, label: "月" },
{ days: 90, label: "季" },
{ days: 180, label: "半年" },
{ days: 365, label: "年" },
{ days: 0, label: "一次性" },
];
function ServerIpMeta({ server, agentVersion, onCopy }: { server: AdminServer; agentVersion: string | null; onCopy: (ip: string) => void }) {
const entries = [
{ family: "v4" as const, ip: server.ip_v4 || "" },
{ family: "v6" as const, ip: server.ip_v6 || "" },
].filter((entry) => entry.ip);
return (
<div className="server-name-meta">
{entries.map((entry) => (
<span className="server-ip-entry" key={entry.family}>
<span className={`ip-badge ${entry.family}`}>{entry.family === "v6" ? "IPv6" : "IPv4"}</span>
<button type="button" className="ip-value" title={`点击复制:${entry.ip}`} onClick={() => onCopy(entry.ip)}>{entry.ip}</button>
</span>
))}
{!entries.length ? <span className="meta-item">尚未探测到公网 IP</span> : null}
<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) : "";
}
function formatSessionTime(value: number) {
return new Date(value * 1000).toLocaleString();
}
function describeLoginDevice(userAgent: string) {
const browser = userAgent.includes("Edg/") ? "Edge"
: userAgent.includes("Firefox/") ? "Firefox"
: userAgent.includes("Chrome/") ? "Chrome"
: userAgent.includes("Safari/") ? "Safari"
: "其他客户端";
const system = userAgent.includes("Android") ? "Android"
: /iPhone|iPad/.test(userAgent) ? "iOS"
: userAgent.includes("Windows") ? "Windows"
: userAgent.includes("Mac OS") ? "macOS"
: userAgent.includes("Linux") ? "Linux"
: "";
return system ? `${browser} · ${system}` : browser;
}
function settingPatch(settings: Settings, key: keyof Settings, value: unknown) {
return { ...settings, [key]: value } as Settings;
}
function shellLiteral(value: string) {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
function powershellLiteral(value: string) {
return `'${value.replaceAll("'", "''")}'`;
}
async function copyText(value: string) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {}
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.readOnly = true;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
if (!copied) throw new Error("copy failed");
}
async function waitForDatabaseSwitch(targetKind: DatabaseStats["kind"]) {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
await new Promise((resolve) => window.setTimeout(resolve, 750));
try {
const response = await fetch(`/api/admin/database?restart=${Date.now()}`, {
cache: "no-store",
credentials: "same-origin",
signal: AbortSignal.timeout(2_500),
});
if (response.status === 401) return;
if (response.ok) {
const database = await response.json() as DatabaseStats;
if (database.kind === targetKind && !database.restart_required) return;
}
} catch {}
}
throw new Error("NodeFlare 未自动恢复,请在主机上检查服务状态");
}
export function AdminPanel({
config,
dark,
onToggleTheme,
onChanged,
}: {
config: Config;
dark: boolean;
onToggleTheme: () => void;
onChanged: () => void;
}) {
const [authenticated, setAuthenticated] = useState(false);
const [authChecked, setAuthChecked] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileReset, setTurnstileReset] = useState(0);
const [loginTotpCode, setLoginTotpCode] = useState("");
const [loginTotpChallenge, setLoginTotpChallenge] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [tab, setTab] = useState<AdminTab>(() => adminTabFromPath(window.location.pathname));
const [servers, setServers] = useState<AdminServer[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [draggingId, setDraggingId] = useState("");
const [settings, setSettings] = useState<Settings | null>(null);
const [database, setDatabase] = useState<DatabaseStats | null>(null);
const [databaseMigrationUrl, setDatabaseMigrationUrl] = useState("");
const [databaseMigrationResult, setDatabaseMigrationResult] = useState<DatabaseMigrationResult | null>(null);
const [restarting, setRestarting] = useState(false);
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
const [exchangeRatesExpanded, setExchangeRatesExpanded] = useState(false);
const [themes, setThemes] = useState<Theme[]>([]);
const [themeName, setThemeName] = useState("");
const [themeDescription, setThemeDescription] = useState("");
const [themeUrl, setThemeUrl] = useState("");
const [themeSourceMode, setThemeSourceMode] = useState<ThemeSourceMode>("repository");
const [themeFile, setThemeFile] = useState<File | null>(null);
const [themeSettingsSchema, setThemeSettingsSchema] = useState<ThemeSettingsSchema | null>(null);
const [editing, setEditing] = useState<AdminServer | "new" | null>(null);
const [form, setForm] = useState<ServerInput>(emptyServer);
const [install, setInstall] = useState<AgentInstallInfo | null>(null);
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");
const [remoteSelectedIds, setRemoteSelectedIds] = useState<string[]>([]);
const [remoteQuery, setRemoteQuery] = useState("");
const [remoteCommand, setRemoteCommand] = useState("");
const [remoteTasks, setRemoteTasks] = useState<RemoteTask[]>([]);
const [remoteTotpCode, setRemoteTotpCode] = useState("");
const [twoFactorStatus, setTwoFactorStatus] = useState<TotpStatus | null>(null);
const [twoFactorSetup, setTwoFactorSetup] = useState<TotpSetup | null>(null);
const [twoFactorCode, setTwoFactorCode] = useState("");
const [securityVerification, setSecurityVerification] = useState("");
const [databaseVerification, setDatabaseVerification] = useState("");
const [twoFactorSecretCopied, setTwoFactorSecretCopied] = useState(false);
const [loginSessions, setLoginSessions] = useState<LoginSession[]>([]);
const [loginSessionsLoaded, setLoginSessionsLoaded] = useState(false);
const [revokingSessionId, setRevokingSessionId] = useState("");
const themeFileInputRef = useRef<HTMLInputElement>(null);
const databaseRestoreInputRef = useRef<HTMLInputElement>(null);
const remoteTasksRef = useRef<RemoteTask[]>([]);
const remoteTasksRequestRef = useRef(0);
const remoteTasksActive = useMemo(() => hasActiveRemoteTasks(remoteTasks), [remoteTasks]);
const remotePayloadReady = Boolean(remoteCommand.trim());
const remoteTotpReady = twoFactorStatus?.enabled === true && /^\d{6}$/.test(remoteTotpCode);
const loginTotpRequired = config.totp_login_enabled || loginTotpChallenge;
remoteTasksRef.current = remoteTasks;
const load = useCallback(async () => {
setBusy(true);
setError("");
try {
const [serverResult, settingsResult, themesResult, twoFactorResult] = await Promise.all([
api.adminServers(),
api.settings(),
api.themes(),
api.twoFactorStatus(),
]);
setServers(serverResult.servers);
const serverIds = new Set(serverResult.servers.map((server) => server.id));
setRemoteSelectedIds((current) => current.filter((id) => serverIds.has(id)));
setThemes(themesResult.themes);
setSettings(settingsResult);
setTwoFactorStatus(twoFactorResult);
setAuthenticated(true);
} catch (reason) {
if (reason instanceof ApiError && reason.status === 401) {
setAuthenticated(false);
setError("");
return;
}
setError(reason instanceof Error ? reason.message : "加载失败");
} finally {
setAuthChecked(true);
setBusy(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
if (!authChecked) return;
const syncAdminPath = () => {
const canonicalPath = canonicalAdminPath(window.location.pathname, authenticated);
if (window.location.pathname !== canonicalPath) {
window.history.replaceState(null, "", canonicalPath);
}
setTab(adminTabFromPath(canonicalPath));
setNotice("");
setError("");
};
syncAdminPath();
const handlePopState = () => syncAdminPath();
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [authChecked, authenticated]);
useEffect(() => {
if (!authenticated) return;
if (tab === "themeSettings") void loadThemeSettings();
if (tab === "data" && !database) void loadDatabase();
if (tab === "security") void loadLoginSessions();
}, [authenticated, tab]);
useEffect(() => {
const resetAuthentication = () => {
setAuthenticated(false);
setAuthChecked(true);
setError("");
setNotice("");
};
window.addEventListener(ADMIN_UNAUTHORIZED_EVENT, resetAuthentication);
return () => window.removeEventListener(ADMIN_UNAUTHORIZED_EVENT, resetAuthentication);
}, []);
useEffect(() => {
if (!notice) return;
const timer = window.setTimeout(() => setNotice(""), 3200);
return () => window.clearTimeout(timer);
}, [notice]);
useEffect(() => {
if (!error) return;
const timer = window.setTimeout(() => setError(""), 6000);
return () => window.clearTimeout(timer);
}, [error]);
useEffect(() => {
if (!twoFactorSecretCopied) return;
const timer = window.setTimeout(() => setTwoFactorSecretCopied(false), 1800);
return () => window.clearTimeout(timer);
}, [twoFactorSecretCopied]);
async function login(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
const passwordDerived = await derivePassword(password, config.password_client_salt);
await api.login(username.trim(), passwordDerived, turnstileToken, loginTotpCode);
setPassword("");
setTurnstileToken("");
setLoginTotpCode("");
setLoginTotpChallenge(false);
setAuthenticated(true);
await load();
} catch (reason) {
if (reason instanceof ApiError && reason.status === 428) setLoginTotpChallenge(true);
setError(reason instanceof Error ? reason.message : "登录失败");
setTurnstileToken("");
setTurnstileReset((value) => value + 1);
} finally { setBusy(false); }
}
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 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("");
}
function updatePriceText(raw: string) {
if (!/^-?\d*\.?\d*$/.test(raw)) return;
setPriceText(raw);
const parsed = Number(raw);
if (Number.isFinite(parsed)) updateForm("price", parsed);
}
function sizeInputProps(text: string, setText: (value: string) => void, commit: (bytes: number) => void, current: number) {
return {
value: text,
inputMode: "decimal" as const,
onChange: (event: ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
setText(raw);
const parsed = parseByteSize(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(rxCurrentBytes - rxBaseBytes),
tx_correction: Math.round(txCurrentBytes - txBaseBytes),
};
try {
if (editing === "new") {
const result = await api.createServer(payload);
setInstall({ agent_token: result.agent_token, agent_mirror: form.agent_mirror });
} else if (editing) {
await api.updateServer(editing.id, payload);
}
setEditing(null);
await load();
onChanged();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "保存节点失败");
} finally { setBusy(false); }
}
async function removeSelected() {
if (!selectedIds.length || !window.confirm(`确认删除选中的 ${selectedIds.length} 个节点及其全部历史数据?`)) return;
setBusy(true);
try {
await api.deleteServers(selectedIds);
setSelectedIds([]);
await load();
onChanged();
} catch (reason) { setError(reason instanceof Error ? reason.message : "批量删除失败"); }
finally { setBusy(false); }
}
async function remove(server: AdminServer) {
if (!window.confirm(`确认删除“${server.name}”及其全部历史数据?`)) return;
try { await api.deleteServer(server.id); await load(); onChanged(); }
catch (reason) { setError(reason instanceof Error ? reason.message : "删除失败"); }
}
async function showInstallCommand(server: AdminServer) {
if (!window.confirm(`重新生成“${server.name}”的 Agent Token?当前 Agent 会立即离线,需使用新安装命令重新连接。`)) return;
setBusy(true);
setError("");
try {
const { agent_token } = await api.rotateServerToken(server.id);
setInstall({ agent_token, agent_mirror: server.agent_mirror });
} catch (reason) {
setError(reason instanceof Error ? reason.message : "重置 Agent Token 失败");
} finally {
setBusy(false);
}
}
async function copyInstallCommand() {
try {
if (installCommand) await copyText(installCommand);
setNotice("安装命令已复制");
} catch {
setError("复制失败,请手动选择命令复制");
}
}
async function copyServerIp(ip: string) {
try {
await copyText(ip);
setNotice("IP 已复制");
} catch {
setError("复制失败,请手动选择复制");
}
}
async function move(index: number, offset: number) {
const target = index + offset;
if (target < 0 || target >= servers.length) return;
const next = [...servers];
[next[index], next[target]] = [next[target], next[index]];
setServers(next);
try { await api.reorderServers(next.map((server) => server.id)); onChanged(); }
catch (reason) { setServers(servers); setError(reason instanceof Error ? reason.message : "排序失败"); }
}
function startDrag(event: DragEvent<HTMLButtonElement>, id: string) {
setDraggingId(id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", id);
}
async function dropServer(event: DragEvent<HTMLDivElement>, targetId: string) {
event.preventDefault();
const sourceId = draggingId || event.dataTransfer.getData("text/plain");
setDraggingId("");
if (!sourceId || sourceId === targetId) return;
const sourceIndex = servers.findIndex((server) => server.id === sourceId);
const targetIndex = servers.findIndex((server) => server.id === targetId);
if (sourceIndex < 0 || targetIndex < 0) return;
const previous = [...servers];
const next = [...servers];
const [moved] = next.splice(sourceIndex, 1);
next.splice(targetIndex, 0, moved);
setServers(next);
try { await api.reorderServers(next.map((server) => server.id)); onChanged(); }
catch (reason) { setServers(previous); setError(reason instanceof Error ? reason.message : "排序失败"); }
}
async function saveSite(event: FormEvent) {
event.preventDefault();
if (!settings) return;
setBusy(true);
setError("");
setNotice("");
const payload: Partial<Settings> = { ...settings };
delete payload.admin_password_configured;
delete payload.totp_login_enabled;
delete payload.current_password_derived;
delete payload.current_totp_code;
try {
if (payload.new_password) {
payload.new_password_derived = await derivePassword(payload.new_password, config.password_client_salt);
delete payload.new_password;
} else {
delete payload.new_password;
delete payload.new_password_derived;
}
if (tab === "security") {
const verification = securityVerification.trim();
if (twoFactorStatus?.enabled) {
if (!/^\d{6}$/.test(verification)) throw new Error("请输入当前 6 位两步验证码");
payload.current_totp_code = verification;
} else {
if (!verification) throw new Error("请输入当前管理员密码");
payload.current_password_derived = await derivePassword(
verification,
config.password_client_salt,
);
}
}
const result = await api.saveSettings(payload);
setSettings(result);
setSecurityVerification("");
if (tab === "security") await loadLoginSessions();
setNotice("设置已保存");
onChanged();
} catch (reason) { setError(reason instanceof Error ? reason.message : "保存设置失败"); }
finally { setBusy(false); }
}
async function loadDatabase() {
setBusy(true); setError("");
try {
const [stats, rates] = await Promise.all([api.databaseStats(), api.exchangeRates()]);
setDatabase(stats);
if (!stats.restart_required) setDatabaseMigrationResult(null);
setExchangeRates(rates);
}
catch (reason) { setError(reason instanceof Error ? reason.message : "读取数据库统计失败"); }
finally { setBusy(false); }
}
async function sensitiveProof(value: string, action: string) {
const verification = value.trim();
if (twoFactorStatus?.enabled) {
if (!/^\d{6}$/.test(verification)) throw new Error(`${action}请输入当前 6 位两步验证码`);
return { totpCode: verification };
}
if (!verification) throw new Error(`${action}请输入当前管理员密码`);
return {
passwordDerived: await derivePassword(verification, config.password_client_salt),
};
}
async function reclaimDatabase() {
if (!window.confirm("回收空间期间数据库会短暂不可用,确认继续?")) return;
setBusy(true); setError(""); setNotice("");
try {
const result = await api.reclaimDatabase();
setDatabase(result.database);
setNotice(result.reclaimed_bytes > 0 ? `已回收 ${formatBytes(result.reclaimed_bytes)}` : "数据库已整理,当前没有可释放空间");
} catch (reason) { setError(reason instanceof Error ? reason.message : "回收数据库空间失败"); }
finally { setBusy(false); }
}
async function migrateDatabase() {
const databaseUrl = databaseMigrationUrl.trim();
if (!databaseUrl) return;
const source = database?.kind === "postgresql" ? "PostgreSQL" : "SQLite";
const target = database?.kind === "postgresql" ? "SQLite" : "PostgreSQL";
if (!window.confirm(`将 ${source} 迁移到 ${target}?目标库中已有的 NodeFlare 数据会被覆盖。`)) return;
setBusy(true); setError(""); setNotice("");
try {
const proof = await sensitiveProof(databaseVerification, "迁移数据库前,");
const result = await api.migrateDatabase(databaseUrl, proof);
setDatabaseMigrationUrl("");
setDatabaseVerification("");
setDatabaseMigrationResult(result);
setDatabase((current) => current ? { ...current, restart_required: result.restart_required } : current);
const targetName = result.target_kind === "postgresql" ? "PostgreSQL" : "SQLite";
setNotice(`数据库已迁移到 ${targetName},共 ${result.migrated_rows.toLocaleString()} 行(${formatBytes(result.size_bytes)})`);
} catch (reason) { setError(reason instanceof Error ? reason.message : "数据库迁移失败"); }
finally { setBusy(false); }
}
async function restartAfterDatabaseMigration() {
if (!database?.restart_required || !window.confirm("立即重启 NodeFlare 并切换到新数据库?管理界面和 Agent 会短暂断开。")) return;
const targetKind = databaseMigrationResult?.target_kind === "postgresql"
? "postgresql"
: databaseMigrationResult?.target_kind === "sqlite"
? "sqlite"
: database.kind === "postgresql" ? "sqlite" : "postgresql";
setBusy(true); setRestarting(true); setError(""); setNotice("NodeFlare 正在重启");
try {
await api.restartAfterDatabaseMigration();
await waitForDatabaseSwitch(targetKind);
window.location.replace("/admin/login");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "重启 NodeFlare 失败");
} finally {
setBusy(false);
setRestarting(false);
}
}
async function refreshExchangeRates() {
setBusy(true); setError(""); setNotice("");
try {
const rates = await api.refreshExchangeRates();
setExchangeRates(rates);
setNotice(`汇率已更新 · ${rates.source} · ${rates.date}`);
} catch (reason) { setError(reason instanceof Error ? reason.message : "汇率更新失败"); }
finally { setBusy(false); }
}
async function exportDatabaseBackup() {
setBusy(true); setError(""); setNotice("");
try {
const proof = await sensitiveProof(databaseVerification, "导出备份前,");
const { blob, filename } = await api.databaseBackup(proof);
setDatabaseVerification("");
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice("数据库备份已导出");
} catch (reason) { setError(reason instanceof Error ? reason.message : "导出数据库备份失败"); }
finally { setBusy(false); }
}
async function restoreDatabaseBackup(file: File) {
if (file.size > 512 * 1024 * 1024) {
setError("数据库备份 ZIP 不能超过 512 MiB");
return;
}
if (!window.confirm("恢复将覆盖当前数据库,并使现有登录会话失效。确认继续?")) return;
setBusy(true); setError(""); setNotice("");
try {
const proof = await sensitiveProof(databaseVerification, "恢复备份前,");
const result = await api.restoreDatabaseBackup(file, proof);
setDatabaseVerification("");
window.alert(`数据库已恢复 ${result.restored_rows.toLocaleString()} 行,请使用备份中的管理员账号重新登录。`);
window.location.reload();
} catch (reason) { setError(reason instanceof Error ? reason.message : "恢复数据库备份失败"); }
finally { setBusy(false); }
}
async function loadThemeSettings() {
setBusy(true); setError("");
try { setThemeSettingsSchema(await api.themeSettings()); }
catch (reason) { setError(reason instanceof Error ? reason.message : "读取主题设置失败"); }
finally { setBusy(false); }
}
async function addTheme(event: FormEvent) {
event.preventDefault();
if (themeSourceMode === "upload" && !themeFile) {
setError("请选择 ZIP 主题文件");
return;
}
if (themeSourceMode === "upload" && themeFile && themeFile.size > 32 * 1024 * 1024) {
setError("主题 ZIP 不能超过 32 MiB");
return;
}
setBusy(true); setError(""); setNotice("");
try {
if (themeSourceMode === "upload" && themeFile) {
await api.uploadTheme({ name: themeName.trim(), description: themeDescription.trim() }, themeFile);
} else {
await api.addTheme({ name: themeName.trim(), description: themeDescription.trim(), url: themeUrl.trim() });
}
setThemeName(""); setThemeDescription(""); setThemeUrl("");
setThemeFile(null);
if (themeFileInputRef.current) themeFileInputRef.current.value = "";
await load();
setNotice(themeSourceMode === "upload" ? "主题 ZIP 已安装" : "最新 Release 主题已安装");
} catch (reason) { setError(reason instanceof Error ? reason.message : "添加主题失败"); }
finally { setBusy(false); }
}
async function activateTheme(theme: Theme) {
if (theme.active) return;
setBusy(true); setError(""); setNotice("");
try {
await api.activateTheme(theme.id);
await load();
setNotice(`已启用主题:${theme.name}`);
onChanged();
} catch (reason) { setError(reason instanceof Error ? reason.message : "启用主题失败"); }
finally { setBusy(false); }
}
async function previewTheme(theme: Theme) {
if (theme.builtin) return;
const previewWindow = window.open("", "_blank");
setBusy(true); setError("");
try {
const { preview_url } = await api.previewTheme(theme.id);
if (previewWindow) {
previewWindow.opener = null;
previewWindow.location.replace(preview_url);
} else {
window.open(preview_url, "_blank", "noopener,noreferrer");
}
} catch (reason) {
previewWindow?.close();
setError(reason instanceof Error ? reason.message : "创建主题预览失败");
} finally { setBusy(false); }
}
async function removeTheme(theme: Theme) {
if (theme.builtin || !window.confirm(`确认删除主题“${theme.name}”?`)) return;
setBusy(true); setError(""); setNotice("");
try {
await api.deleteTheme(theme.id);
await load();
setNotice("主题已删除");
} catch (reason) { setError(reason instanceof Error ? reason.message : "删除主题失败"); }
finally { setBusy(false); }
}
async function loadLoginSessions() {
try {
const result = await api.loginSessions();
setLoginSessions(result.sessions);
setLoginSessionsLoaded(true);
} catch (reason) {
if (!(reason instanceof ApiError && reason.status === 401)) {
setError(reason instanceof Error ? reason.message : "读取登录设备失败");
}
}
}
async function revokeLoginSession(session: LoginSession) {
if (session.current || !window.confirm(`确认让“${describeLoginDevice(session.user_agent)}”下线?`)) return;
setRevokingSessionId(session.id); setError(""); setNotice("");
try {
await api.revokeLoginSession(session.id);
setLoginSessions((current) => current.filter((item) => item.id !== session.id));
setNotice("设备已踢下线");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "设备下线失败");
} finally { setRevokingSessionId(""); }
}
async function logout() {
try { await api.logout(); }
finally {
setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
setTwoFactorStatus(null); setTwoFactorSetup(null); setTwoFactorCode(""); setRemoteTotpCode("");
setTwoFactorSecretCopied(false);
setLoginSessions([]); setLoginSessionsLoaded(false); setRevokingSessionId("");
setRemoteSelectedIds([]); setRemoteCommand(""); setRemoteTasks([]); setRemoteQuery("");
}
}
async function setupTwoFactor() {
setBusy(true); setError(""); setNotice("");
try {
const proof = await sensitiveProof(securityVerification, "生成两步验证密钥前,");
const setup = await api.setupTwoFactor(proof);
setSecurityVerification("");
setTwoFactorSetup(setup);
setTwoFactorStatus({ enabled: false, has_secret: true });
setTwoFactorCode("");
setTwoFactorSecretCopied(false);
setNotice("两步验证密钥已生成,请先加入验证器再启用");
} catch (reason) { setError(reason instanceof Error ? reason.message : "生成两步验证密钥失败"); }
finally { setBusy(false); }
}
async function enableTwoFactor() {
if (!/^\d{6}$/.test(twoFactorCode)) { setError("请输入 6 位两步验证码"); return; }
setBusy(true); setError(""); setNotice("");
try {
await api.enableTwoFactor(twoFactorCode);
setTwoFactorStatus({ enabled: true, has_secret: true });
setTwoFactorSetup(null);
setTwoFactorCode("");
setTwoFactorSecretCopied(false);
setNotice("两步验证已启用");
onChanged();
} catch (reason) { setError(reason instanceof Error ? reason.message : "启用两步验证失败"); }
finally { setBusy(false); }
}
async function disableTwoFactor() {
if (!/^\d{6}$/.test(twoFactorCode)) { setError("请输入 6 位两步验证码"); return; }
setBusy(true); setError(""); setNotice("");
try {
await api.disableTwoFactor(twoFactorCode);
setTwoFactorStatus({ enabled: false, has_secret: true });
setTwoFactorCode("");
setNotice("两步验证已禁用");
onChanged();
} catch (reason) { setError(reason instanceof Error ? reason.message : "禁用两步验证失败"); }
finally { setBusy(false); }
}
async function copyTwoFactorSecret() {
if (!twoFactorSetup) return;
try {
await copyText(twoFactorSetup.secret);
setTwoFactorSecretCopied(true);
} catch {
setError("复制失败,请手动选择复制");
}
}
const installCommand = useMemo(() => {
if (!install) return "";
const origin = window.location.origin;
const installer = `${origin}/agent`;
const mirror = install.agent_mirror.trim().replace(/\/+$/, "");
const shellMirror = mirror ? ` -m ${shellLiteral(mirror)}` : "";
const powershellMirror = mirror ? ` -Mirror ${powershellLiteral(mirror)}` : "";
if (installPlatform === "windows") {
return `Invoke-WebRequest -UseBasicParsing -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\nUnblock-File "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(install.agent_token)}${powershellMirror}`;
}
if (installPlatform === "macos") {
return `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
}
if (installPlatform === "freebsd") {
return `fetch -qo - ${installer}/install-freebsd.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
}
return `curl -fsSL ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
}, [install, installPlatform]);
const serverDialog = useDialog<HTMLFormElement>(editing !== null, () => setEditing(null));
const installDialog = useDialog<HTMLElement>(Boolean(installCommand), () => setInstall(null));
const toggleSelected = (id: string) => setSelectedIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
const allSelected = servers.length > 0 && selectedIds.length === servers.length;
const updateForm = <K extends keyof ServerInput>(key: K, value: ServerInput[K]) => setForm((current) => ({ ...current, [key]: value }));
const updateSettings = <K extends keyof Settings>(key: K, value: Settings[K]) => setSettings((current) => current ? settingPatch(current, key, value) : current);
const updateThemeOption = (key: string, value: ThemeSettingValue) => setSettings((current) => current ? {
...current,
theme_options: { ...current.theme_options, [key]: value },
} : current);
const selectTab = (next: AdminTab) => {
const path = adminTabPaths[next];
if (window.location.pathname !== path) window.history.pushState(null, "", path);
setTab(next);
setNotice("");
setError("");
};
const siteLogoUrl = settings ? settings.logo_url : config.logo_url;
const remoteVisibleServers = useMemo(() => {
const keyword = remoteQuery.trim().toLowerCase();
if (!keyword) return servers;
return servers.filter((server) => `${server.name} ${server.region} ${server.group_name} ${server.last_ip}`.toLowerCase().includes(keyword));
}, [remoteQuery, servers]);
const remoteServerById = useMemo(() => new Map(servers.map((server) => [server.id, server])), [servers]);
const remoteAllSelected = servers.length > 0 && remoteSelectedIds.length === servers.length;
const toggleRemoteServer = (serverId: string) => {
setRemoteSelectedIds((current) => current.includes(serverId)
? current.filter((id) => id !== serverId)
: [...current, serverId]);
};
const refreshRemoteTasks = useCallback(async (quiet = false, includeCompleted = false) => {
const snapshot = remoteTasksRef.current.filter((task) => includeCompleted || isRemoteTaskActive(task.status));
if (!snapshot.length) return;
const requestId = ++remoteTasksRequestRef.current;
const responses = await Promise.allSettled(snapshot.map((task) => api.remoteTask(task.id)));
if (requestId !== remoteTasksRequestRef.current) return;
const updates = new Map<string, RemoteTask>();
let failed = false;
responses.forEach((response) => {
if (response.status === "fulfilled") updates.set(response.value.id, response.value);
else failed = true;
});
setRemoteTasks((current) => current.map((task) => updates.get(task.id) ?? task));
if (failed && !quiet) setError("部分执行结果刷新失败");
}, []);
const createRemoteTask = async (event: FormEvent) => {
event.preventDefault();
if (!remoteSelectedIds.length) {
setError("请选择至少一台服务器");
return;
}
if (!remoteCommand.trim()) {
setError("请输入命令");
return;
}
if (twoFactorStatus === null) {
setError("正在读取两步验证状态,请稍候");
return;
}
if (!twoFactorStatus.enabled) {
setError("请先在登录与安全中启用 TOTP 两步验证");
return;
}
if (!/^\d{6}$/.test(remoteTotpCode)) {
setError("远程执行需要 6 位两步验证码");
return;
}
setBusy(true);
setError("");
try {
const command = remoteCommand.trim();
const data = await api.createRemoteTask({
server_ids: remoteSelectedIds,
command,
totp_code: remoteTotpCode,
});
const requestedAt = Math.floor(Date.now() / 1000);
const tasks = data.tasks.map(({ server_id, task_id }) => ({
id: task_id,
server_id,
command,
status: "pending" as const,
requested_by: "",
requested_at: requestedAt,
started_at: null,
completed_at: null,
result: "",
exit_code: null,
}));
remoteTasksRequestRef.current += 1;
remoteTasksRef.current = tasks;
setRemoteTasks(tasks);
setRemoteCommand("");
setRemoteTotpCode("");
setNotice(`已向 ${tasks.length} 台服务器下发命令`);
await refreshRemoteTasks(true, true);
} catch (err) {
setError(err instanceof Error ? err.message : "下发命令失败");
} finally {
setBusy(false);
}
};
useEffect(() => {
if (!authenticated || tab !== "remote" || !remoteTasksActive) return;
let stopped = false;
let running = false;
let timer: number | undefined;
const canPoll = () => !document.hidden && navigator.onLine !== false;
const clearTimer = () => {
if (timer !== undefined) {
window.clearTimeout(timer);
timer = undefined;
}
};
const schedule = () => {
clearTimer();
if (!stopped && canPoll()) {
timer = window.setTimeout(run, REMOTE_TASK_POLL_INTERVAL_MS);
}
};
const run = async () => {
clearTimer();
if (stopped || running || !canPoll()) return;
running = true;
try {
await refreshRemoteTasks(true);
} finally {
running = false;
schedule();
}
};
const resume = () => {
clearTimer();
if (!stopped && canPoll()) void run();
};
const pause = () => clearTimer();
const handleVisibility = () => {
if (document.hidden) pause();
else resume();
};
document.addEventListener("visibilitychange", handleVisibility);
window.addEventListener("online", resume);
window.addEventListener("offline", pause);
schedule();
return () => {
stopped = true;
clearTimer();
document.removeEventListener("visibilitychange", handleVisibility);
window.removeEventListener("online", resume);
window.removeEventListener("offline", pause);
};
}, [authenticated, refreshRemoteTasks, remoteTasksActive, tab]);
if (!authChecked) {
return <div className={`admin-page ${dark ? "admin-dark" : ""}`} aria-busy="true" />;
}
return (
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
{authenticated ? (error ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
: notice ? <div className="admin-toast" role="status" aria-live="polite"><CircleCheck aria-hidden="true" /><span>{notice}</span></div> : null) : null}
{!authenticated ? <div className="admin-login-stage">
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={15} /> : <Moon size={15} />}</button>
<form className="login-form glass-panel" onSubmit={login}>
<SiteLogo src={siteLogoUrl} alt="" width="48" height="48" />
<div className="login-copy"><h1>管理员登录</h1><p>{config.site_name}</p></div>
<label><span>用户名</span><input autoFocus type="text" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={error ? "login-error" : undefined} /></label>
<label><span>密码</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={error ? "login-error" : undefined} /></label>
{loginTotpRequired ? <label><span>两步验证码</span><input autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={loginTotpCode} onChange={(event) => setLoginTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} required aria-invalid={error ? true : undefined} aria-describedby={error ? "login-error" : undefined} /></label> : null}
{config.turnstile_login_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
{error ? <p className="login-error" id="login-error" role="alert"><CircleAlert size={15} aria-hidden="true" />{error}</p> : null}
<button className="primary-btn login-submit" disabled={busy || (loginTotpRequired && !/^\d{6}$/.test(loginTotpCode)) || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={15} />{busy ? "验证中" : "登录"}</button>
</form>
</div> : <section className="admin-shell" aria-label="管理面板">
<header className="admin-topbar">
<div className="admin-brand">
<SiteLogo src={siteLogoUrl} alt="" width="36" height="36" />
<strong>{config.site_name}</strong>
</div>
<div className="admin-topbar-actions">
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"} aria-label={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={15} /> : <Moon size={15} />}</button>
<a className="admin-home-link" href="/" target="_blank" rel="noopener noreferrer" title="主页" aria-label="主页"><ArrowLeft size={15} /></a>
<button type="button" onClick={() => void logout()} title="退出" aria-label="退出"><LogOut size={15} /></button>
</div>
</header>
<div className="admin-body">
<aside className="admin-sidebar">
<nav className="admin-tabs" aria-label="管理导航">
{adminNavigation.map((item) => {
const Icon = item.icon;
return <a key={item.tab} href={adminTabPaths[item.tab]} className={tab === item.tab ? "active" : ""} aria-current={tab === item.tab ? "page" : undefined} onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
selectTab(item.tab);
}}><Icon size={17} />{item.label}</a>;
})}
</nav>
</aside>
<div className="admin-content">
<header className="admin-content-header"><h1>{adminPages[tab].title}</h1><p>{adminPages[tab].description}</p></header>
{tab === "servers" ? (
<div className="admin-section">
<div className="section-head"><div><h3>监控节点</h3><span>{servers.length} 个节点 · 可拖动上下排序</span></div><div className="section-actions"><button className="primary-btn compact" onClick={() => openEditor()}><Plus size={15} />添加</button></div></div>
<div className="batch-toolbar"><label className="select-all"><Checkbox checked={allSelected} onChange={() => setSelectedIds(allSelected ? [] : servers.map((server) => server.id))} />全选</label>{selectedIds.length ? <button className="danger-btn compact" onClick={() => void removeSelected()}><Trash2 size={15} />删除选中 ({selectedIds.length})</button> : <span>批量操作</span>}</div>
<div className="server-list">
{servers.map((server, index) => (
<div className={`server-row ${draggingId === server.id ? "dragging" : ""}`} key={server.id} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={(event) => void dropServer(event, server.id)}>
<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></div><ServerIpMeta server={server} 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="重置 Token 并生成安装命令"><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>
))}
{!servers.length && !busy ? <div className="list-empty">暂无节点</div> : null}
</div>
</div>
) : tab === "latency" ? (
<LatencyManager servers={servers} onError={setError} onNotice={setNotice} />
) : tab === "themes" && settings ? (
<div className="theme-store-page">
<section className="admin-section">
<div className="section-head"><h3>主题列表</h3></div>
<div className="theme-list">
{themes.map((theme) => {
const uploaded = theme.url.startsWith("upload:");
return <article className={`theme-row ${theme.active ? "active" : ""}`} key={theme.id}>
<div className="theme-row-main">
<div className="theme-row-title"><strong>{theme.name}</strong><span className={`theme-badge ${theme.builtin ? "builtin" : uploaded ? "upload" : "remote"}`}>{theme.builtin ? "默认主题" : uploaded ? "上传安装" : "GitHub Release"}</span>{theme.version ? <span className="theme-badge version">v{theme.version}</span> : null}</div>
{!theme.builtin ? <p title={theme.description || undefined}>{theme.description || "暂无主题说明"}</p> : null}
{!theme.builtin && uploaded ? <span className="theme-upload-source"><Upload size={13} /><span>{theme.url.slice("upload:".length)}</span></span> : null}
{!theme.builtin && !uploaded ? <a href={theme.url} target="_blank" rel="noreferrer"><span>{theme.url}</span><ExternalLink size={13} /></a> : null}
</div>
<div className="theme-row-actions">
{!theme.builtin ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void previewTheme(theme)}><Eye size={15} />预览</button> : null}
<button type="button" className={theme.active ? "theme-active-btn" : "primary-btn compact"} disabled={busy || theme.active} onClick={() => void activateTheme(theme)}>{theme.active ? <><Check size={15} />使用中</> : "启用"}</button>
{!theme.builtin ? <button type="button" className="icon-btn danger" disabled={busy} title="删除主题" onClick={() => void removeTheme(theme)}><Trash2 size={15} /></button> : null}
</div>
</article>;
})}
</div>
</section>
<form className="admin-section theme-add-form" onSubmit={addTheme}>
<div className="section-head"><div><h3>安装主题</h3><span>主题包含可执行前端代码,只安装可信来源;安装后不依赖运行时远程资源。</span></div></div>
<div className="segmented theme-source-tabs" role="group" aria-label="主题安装来源">
<button type="button" className={themeSourceMode === "repository" ? "active" : ""} aria-pressed={themeSourceMode === "repository"} onClick={() => setThemeSourceMode("repository")}>GitHub 仓库</button>
<button type="button" className={themeSourceMode === "upload" ? "active" : ""} aria-pressed={themeSourceMode === "upload"} onClick={() => setThemeSourceMode("upload")}>上传</button>
</div>
<p className="settings-hint">{themeSourceMode === "repository" ? "填写仓库主页地址,NodeFlare 会下载 latest Release 中的第一个 ZIP 文件。" : "ZIP 根目录需包含 index.html,也支持外层只有一个目录的打包方式;最大 32 MiB。"}</p>
<div className="form-grid"><label><span>主题名称</span><input required maxLength={80} value={themeName} onChange={(event) => setThemeName(event.target.value)} placeholder="例如:Ocean" /></label>{themeSourceMode === "repository" ? <label><span>GitHub 仓库</span><input required type="url" maxLength={2048} value={themeUrl} onChange={(event) => setThemeUrl(event.target.value)} placeholder="https://github.com/user/theme" /></label> : <label className="theme-file-field"><span>文件</span><input ref={themeFileInputRef} required type="file" accept=".zip,application/zip" onChange={(event) => setThemeFile(event.target.files?.[0] ?? null)} /><small>{themeFile ? `${themeFile.name} · ${(themeFile.size / 1024 / 1024).toFixed(2)} MiB` : "请选择 .zip 文件"}</small></label>}</div>
<label><span>主题说明(可选)</span><textarea rows={2} maxLength={300} value={themeDescription} onChange={(event) => setThemeDescription(event.target.value)} placeholder="简短描述主题风格和来源" /></label>
<div className="form-actions"><button className="primary-btn" disabled={busy || (themeSourceMode === "upload" && !themeFile)}>{themeSourceMode === "upload" ? <Upload size={15} /> : <Download size={15} />}{busy ? "安装中" : "安装主题"}</button></div>
</form>
</div>
) : settings && (tab === "appearance" || tab === "themeSettings" || tab === "alerts" || tab === "security" || tab === "data") ? (
<form className="settings-form" onSubmit={tab === "data" ? (event) => { event.preventDefault(); void migrateDatabase(); } : saveSite}>
{tab === "appearance" ? <>
<div className="section-title"><Eye size={15} />外观与展示</div>
<div className="form-grid"><label><span>站点名称</span><input required value={settings.site_name} onChange={(event) => updateSettings("site_name", event.target.value)} /></label><label><span>站点描述</span><input value={settings.site_description} onChange={(event) => updateSettings("site_description", event.target.value)} /></label></div>
<label><span>站点公告</span><textarea rows={3} maxLength={1000} value={settings.site_announcement} onChange={(event) => updateSettings("site_announcement", event.target.value)} /></label>
<div className="form-grid"><label><span>界面语言</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>站点 Logo / 浏览器图标地址</span><input type="url" maxLength={1000} value={settings.logo_url} onChange={(event) => updateSettings("logo_url", event.target.value)} placeholder="https://example.com/logo.svg" /></label></div>
<div className="form-grid"><label><span>离线判定(秒)</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>历史保留(天)</span><input type="number" min="1" max="30" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
</> : null}
{tab === "themeSettings" ? <>
<div className="section-title"><SlidersHorizontal size={15} />通用主题设置</div>
<div className="form-grid"><label><span>默认主题</span><select value={settings.default_theme} onChange={(event) => updateSettings("default_theme", event.target.value as Settings["default_theme"])}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></label><label><span>背景图地址</span><input type="text" maxLength={1000} value={settings.background_url} onChange={(event) => updateSettings("background_url", event.target.value)} placeholder="https://example.com/light.webp | https://example.com/dark.webp" /></label></div>
<p className="settings-hint">仅支持 HTTPS 地址;浅色和深色背景可用 | 分隔。</p>
<div className="section-subtitle">当前主题选项</div><div className="theme-option-grid"><Toggle label="公开仪表盘" checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} />{themeSettingsSchema?.settings.map((field) => <ThemeOption key={field.key} field={field} value={settings.theme_options[field.key] ?? field.default} onChange={(value) => updateThemeOption(field.key, value)} />)}</div>
<div className="section-subtitle">公开界面元素</div>
<div className="settings-toggles"><Toggle label="显示搜索" checked={settings.show_search} onChange={(value) => updateSettings("show_search", value)} /><Toggle label="显示分组" checked={settings.show_groups} onChange={(value) => updateSettings("show_groups", value)} /><Toggle label="总览统计" checked={settings.show_stats} onChange={(value) => updateSettings("show_stats", value)} /><Toggle label="资产统计" checked={settings.show_assets} onChange={(value) => updateSettings("show_assets", value)} /><Toggle label="累计流量" checked={settings.show_traffic} onChange={(value) => updateSettings("show_traffic", value)} /><Toggle label="实时网速" checked={settings.show_speed} onChange={(value) => updateSettings("show_speed", value)} /><Toggle label="价格信息" checked={settings.show_price} onChange={(value) => updateSettings("show_price", value)} /><Toggle label="到期信息" checked={settings.show_expiry} onChange={(value) => updateSettings("show_expiry", value)} /><Toggle label="延迟与丢包" checked={settings.show_latency} onChange={(value) => updateSettings("show_latency", value)} /><Toggle label="在线时长" checked={settings.show_uptime} onChange={(value) => updateSettings("show_uptime", value)} /></div>
</> : null}
{tab === "alerts" ? <>
<div className="section-title"><AlertTriangle size={15} />通知与告警</div>
<Toggle label="启用通知与告警" checked={settings.notification_enabled} onChange={(value) => updateSettings("notification_enabled", value)} />
<div className="form-grid three"><label><span>离线告警延迟(分钟)</span><input type="number" min="2" max="1440" value={settings.offline_alert_minutes} onChange={(event) => updateSettings("offline_alert_minutes", Number(event.target.value))} /></label><label><span>到期提醒(天)</span><input type="number" min="0" max="365" value={settings.expiry_alert_days} onChange={(event) => updateSettings("expiry_alert_days", Number(event.target.value))} /></label><label><span>流量提醒起始阈值(%)</span><input type="number" min="50" max="100" value={settings.traffic_alert_percentage} onChange={(event) => updateSettings("traffic_alert_percentage", Number(event.target.value))} /></label></div>
<p className="settings-hint">流量达到起始阈值后,每增加 5 个百分点生成一次事件,最多提醒到 100%。</p>
<TelegramSettings onError={setError} onNotice={setNotice} />
<AlertRuleManager servers={servers} onError={setError} onNotice={setNotice} />
</> : null}
{tab === "security" ? <>
<div className="section-title"><ShieldCheck size={15} />账号与 Cloudflare 防护</div>
<div className="form-grid three"><label><span>管理员用户名</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>新密码(留空不修改)</span><input autoComplete="new-password" type="password" minLength={8} maxLength={128} value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label><label><span>{twoFactorStatus?.enabled ? "当前两步验证码" : "当前管理员密码"}</span><input type="password" autoComplete={twoFactorStatus?.enabled ? "one-time-code" : "current-password"} inputMode={twoFactorStatus?.enabled ? "numeric" : undefined} maxLength={twoFactorStatus?.enabled ? 6 : 128} value={securityVerification} onChange={(event) => setSecurityVerification(twoFactorStatus?.enabled ? event.target.value.replace(/\D/g, "").slice(0, 6) : event.target.value)} required /></label></div>
<div className="two-factor-panel">
<div className="two-factor-head"><div><div className="section-subtitle">TOTP 两步验证</div><p className="settings-hint">启用后管理员登录和每次远程执行都必须提交验证器生成的 6 位动态码。</p></div><span className={`two-factor-status ${twoFactorStatus?.enabled ? "enabled" : ""}`}>{twoFactorStatus?.enabled ? "已启用" : twoFactorStatus ? "未启用" : "读取中"}</span></div>
{twoFactorSetup ? <div className="two-factor-setup">
<label><span>验证器密钥</span><div className="copy-field"><input readOnly value={twoFactorSetup.secret} onFocus={(event) => event.currentTarget.select()} onClick={(event) => event.currentTarget.select()} /><button type="button" className={`secondary-btn compact copy-secret-btn ${twoFactorSecretCopied ? "copied" : ""}`} aria-live="polite" onClick={() => void copyTwoFactorSecret()}>{twoFactorSecretCopied ? <Check size={14} /> : <Copy size={14} />}{twoFactorSecretCopied ? "已复制" : "复制密钥"}</button></div></label>
<p className="settings-hint">在 Google Authenticator、Aegis、2FAS 等验证器中手动输入该密钥,再填写当前 6 位验证码确认。</p>
</div> : null}
{twoFactorStatus?.enabled ? <div className="two-factor-actions"><label><span>当前验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={twoFactorCode} onChange={(event) => setTwoFactorCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="000000" /></label><button type="button" className="danger-btn" disabled={busy} onClick={() => void disableTwoFactor()}>禁用两步验证</button></div> : <div className="two-factor-actions">
{twoFactorSetup ? <label><span>当前验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={twoFactorCode} onChange={(event) => setTwoFactorCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="000000" /></label> : <p className="settings-hint">{twoFactorStatus?.has_secret ? "已有未启用的密钥;重新生成后,旧密钥会失效。" : "尚未生成两步验证密钥。"}</p>}
<button type="button" className="secondary-btn two-factor-generate-btn" disabled={busy} onClick={() => void setupTwoFactor()}>{twoFactorStatus?.has_secret ? "重新生成密钥" : "生成密钥"}</button>
{twoFactorSetup ? <button type="button" className="primary-btn" disabled={busy} onClick={() => void enableTwoFactor()}>启用两步验证</button> : null}
</div>}
</div>
<div className="login-devices-panel">
<div className="login-devices-head"><div><div className="section-subtitle">登录设备</div><p className="settings-hint">踢下线后,对应设备的登录状态会立即失效。</p></div><button type="button" className="secondary-btn compact" disabled={Boolean(revokingSessionId)} onClick={() => void loadLoginSessions()}><RotateCw size={14} />刷新</button></div>
{!loginSessionsLoaded ? <p className="settings-hint">正在读取登录设备...</p> : loginSessions.length ? <div className="login-device-list">{loginSessions.map((session) => <div className="login-device-row" key={session.id}><span className="login-device-icon"><MonitorSmartphone size={18} /></span><div className="login-device-copy"><div className="login-device-title"><strong>{describeLoginDevice(session.user_agent)}</strong>{session.current ? <span className="login-device-current">当前设备</span> : null}</div><span>{session.ip_address || "未知 IP"}</span><small title={session.user_agent}>最近活动 {formatSessionTime(session.last_seen_at)} · 登录于 {formatSessionTime(session.created_at)} · 到期 {formatSessionTime(session.expires_at)}</small></div>{!session.current ? <button type="button" className="danger-btn compact login-device-revoke" disabled={Boolean(revokingSessionId)} onClick={() => void revokeLoginSession(session)}><LogOut size={14} />{revokingSessionId === session.id ? "下线中" : "踢下线"}</button> : null}</div>)}</div> : <p className="settings-hint">暂无有效登录设备。</p>}
</div>
<div className="form-grid"><Toggle label="保护公开仪表盘" checked={settings.turnstile_enabled} onChange={(value) => updateSettings("turnstile_enabled", value)} /><Toggle label="保护管理员登录" checked={settings.turnstile_login_enabled} onChange={(value) => updateSettings("turnstile_login_enabled", value)} /></div>
<div className="form-grid"><label><span>Turnstile Site Key</span><input autoComplete="off" type="password" value={settings.turnstile_site_key} onChange={(event) => updateSettings("turnstile_site_key", event.target.value)} /></label><label><span>Turnstile Secret Key</span><input autoComplete="off" type="password" value={settings.turnstile_secret_key} onChange={(event) => updateSettings("turnstile_secret_key", event.target.value)} /></label></div>
</> : null}
{tab === "data" ? <>
<div className="section-head database-section-head"><div><h3>数据库维护</h3><span>备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。</span></div><div className="section-actions"><label><span className="sr-only">{twoFactorStatus?.enabled ? "当前两步验证码" : "当前管理员密码"}</span><input className="compact-verification-input" type="password" autoComplete={twoFactorStatus?.enabled ? "one-time-code" : "current-password"} inputMode={twoFactorStatus?.enabled ? "numeric" : undefined} maxLength={twoFactorStatus?.enabled ? 6 : 128} value={databaseVerification} onChange={(event) => setDatabaseVerification(twoFactorStatus?.enabled ? event.target.value.replace(/\D/g, "").slice(0, 6) : event.target.value)} placeholder={twoFactorStatus?.enabled ? "当前验证码" : "当前密码"} /></label><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />导出备份</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />恢复备份</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
<div className="database-storage"><div><span>数据库大小</span><strong>{database ? formatBytes(database.size_bytes) : "读取中..."}</strong>{database ? <small>{database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""}</small> : null}</div><button type="button" className="secondary-btn" disabled={busy || !database || database.restart_required} onClick={() => void reclaimDatabase()}><RotateCw size={15} />回收空间</button></div>
<div className="database-migration">
<div><div className="section-title"><ArrowRightLeft size={15} />数据库迁移</div><p className="settings-hint">将当前 {database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 {database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。</p></div>
{database?.restart_required ? <div className="database-migration-ready" role="status" aria-live="polite"><CircleCheck size={20} /><div><strong>迁移完成,等待重启</strong><span>{databaseMigrationResult ? `已复制 ${databaseMigrationResult.migrated_rows.toLocaleString()} 行,新数据库大小 ${formatBytes(databaseMigrationResult.size_bytes)}` : "配置已更新,重启后切换到新数据库"}</span></div><button type="button" className="primary-btn" disabled={busy} onClick={() => void restartAfterDatabaseMigration()}>{restarting ? <RotateCw className="spin" size={15} /> : <Power size={15} />}{restarting ? "正在重启" : "立即重启"}</button></div> : <div className="database-migration-form"><label><span>目标数据库 URL</span><input required type="password" autoComplete="off" maxLength={2048} spellCheck={false} value={databaseMigrationUrl} onChange={(event) => setDatabaseMigrationUrl(event.target.value)} placeholder={database?.kind === "postgresql" ? "sqlite://nodeflare-migrated.db" : "postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=prefer"} /></label><button className="primary-btn" disabled={busy || !databaseMigrationUrl.trim()}><ArrowRightLeft size={15} />{busy ? "迁移中" : "开始迁移"}</button></div>}
</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 ? " · 数据待更新" : ""}` : "正在读取汇率快照"}</p></div><div className="usage-head-actions">{exchangeRatesExpanded ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void refreshExchangeRates()}><RotateCw size={15} />立即更新</button> : null}<button type="button" className="secondary-btn compact usage-toggle" aria-expanded={exchangeRatesExpanded} onClick={() => setExchangeRatesExpanded((expanded) => !expanded)}>{exchangeRatesExpanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />}{exchangeRatesExpanded ? "收起" : "展开"}</button></div></div>
{exchangeRatesExpanded ? 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> : null}
</div>
</> : null}
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={15} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
</form>
) : tab === "remote" ? (
<div className="admin-section remote-section">
<form className="remote-execution-form" onSubmit={(event) => void createRemoteTask(event)}>
<label className="remote-command-field">
<span>执行命令</span>
<textarea autoFocus required rows={5} maxLength={16_384} spellCheck={false} value={remoteCommand} onChange={(event) => setRemoteCommand(event.target.value)} />
<small>多行内容会作为同一次 shell 命令执行。</small>
</label>
<div className="server-picker remote-server-picker">
<div className="server-picker-head"><strong>选择服务器</strong><span>已选 {remoteSelectedIds.length} / 共 {servers.length}</span><button type="button" onClick={() => setRemoteSelectedIds(remoteAllSelected ? [] : servers.map((server) => server.id))}>{remoteAllSelected ? "取消全选" : "全选"}</button></div>
<div className="server-picker-search"><Search size={16} /><input aria-label="搜索远程执行服务器" placeholder="搜索服务器" value={remoteQuery} onChange={(event) => setRemoteQuery(event.target.value)} /></div>
<div className="server-picker-list">
{remoteVisibleServers.map((server) => {
const online = isOnline(server, config.offline_threshold_seconds);
return <label className="server-picker-row remote-server-picker-row" key={server.id}><Checkbox checked={remoteSelectedIds.includes(server.id)} onChange={() => toggleRemoteServer(server.id)} ariaLabel={`选择 ${server.name}`} /><i className={`status-dot ${online ? "online" : ""}`} aria-hidden="true" /><span><strong>{server.name}</strong><small>{online ? "在线" : "离线"} · {server.group_name || "默认"}{server.last_ip ? ` · ${server.last_ip}` : ""}</small></span></label>;
})}
{!remoteVisibleServers.length ? <div className="server-picker-empty">{servers.length ? "没有匹配的服务器" : "暂无服务器"}</div> : null}
</div>
</div>
{twoFactorStatus === null ? <p className="settings-hint">正在确认 TOTP 两步验证状态…</p> : twoFactorStatus.enabled ? <div className="remote-confirm-row"><label><span>2FA 验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={remoteTotpCode} onChange={(event) => setRemoteTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="6 位验证码" required /></label><button className="primary-btn remote-submit" disabled={busy || !remoteSelectedIds.length || !remotePayloadReady || !remoteTotpReady}><Terminal size={15} />{busy ? "下发中" : `确认执行${remoteSelectedIds.length ? ` (${remoteSelectedIds.length})` : ""}`}</button></div> : <div className="remote-2fa-required"><ShieldCheck size={18} /><div><strong>远程执行需要 TOTP 两步验证</strong><span>启用后才能向 Agent 发送命令。</span></div><button type="button" className="secondary-btn compact" onClick={() => selectTab("security")}>前往启用</button></div>}
</form>
{remoteTasks.length > 0 ? <div className="remote-results">
<div className="section-head"><div><h3>执行结果</h3>{remoteTasksActive ? <span className="remote-auto-refresh"><RotateCw size={12} />执行中,每 2 秒自动刷新</span> : <span>本次命令已完成</span>}</div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void refreshRemoteTasks(false, true)}><RotateCw size={14} />刷新结果</button></div>
<div className="remote-command-summary"><span>本次命令</span><code>{remoteTasks[0]?.command}</code></div>
<div className="task-list">
{remoteTasks.map((task) => {
const server = remoteServerById.get(task.server_id);
return <div key={task.id} className="task-item remote-result-item">
<div className="task-header"><strong className="remote-result-server">{server?.name ?? task.server_id}</strong><span className={`task-status ${task.status}`}>{remoteTaskStatusLabels[task.status]}</span><span className="task-time">{new Date(task.requested_at * 1000).toLocaleString()}</span></div>
{task.status === "success" || task.status === "failed" ? <pre className={`task-result ${task.status}`}>{task.result || "(命令没有输出)"}</pre> : <p className="task-progress">{task.status === "pending" ? "等待 Agent 上线接收命令…" : "Agent 已接收,正在等待执行结果…"}</p>}
</div>;
})}
</div>
</div> : null}
</div>
) : tab === "about" ? (
<div className="admin-section about-page">
<div className="about-brand"><SiteLogo alt="" width="52" height="52" /><div><strong>NodeFlare</strong><small>基于 Rust、Axum、SQLite / PostgreSQL 与 WebSocket 的服务器监控</small></div></div>
<div className="about-rows">
<div className="about-row"><span>版本</span><strong>v{VERSION}</strong></div>
<div className="about-row"><span>项目地址</span><a href="https://github.com/imengying/NodeFlare" target="_blank" rel="noreferrer">github.com/imengying/NodeFlare<ExternalLink size={13} /></a></div>
<div className="about-row"><span>开源协议</span><strong>MIT License</strong></div>
</div>
</div>
) : null}
</div>
</div>
</section>}
{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>流量限额(0 不限)</span><input placeholder="如 100 G,不带单位按 GB;0 不限" {...sizeInputProps(trafficLimitText, setTrafficLimitText, (bytes) => updateForm("traffic_limit", bytes), form.traffic_limit)} /></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>历史保存间隔(秒)</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>下行流量当前值</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={form.billing_cycle <= 0 ? "自动续费(一次性不适用)" : "自动续费"} 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}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" role="group" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} aria-pressed={installPlatform === "linux"} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} aria-pressed={installPlatform === "windows"} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} aria-pressed={installPlatform === "macos"} onClick={() => setInstallPlatform("macos")}>macOS ARM</button><button type="button" className={installPlatform === "freebsd" ? "active" : ""} aria-pressed={installPlatform === "freebsd"} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={15} />复制</button></div></section></div> : null}
</div>
);
}
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
return <label className="toggle-row"><span><b>{label}</b></span><Checkbox checked={checked} onChange={onChange} /></label>;
}
function ThemeOption({ field, value, onChange }: { field: ThemeSettingField; value: ThemeSettingValue | undefined; onChange: (value: ThemeSettingValue) => void }) {
if (field.type === "toggle") {
return <Toggle label={field.label} checked={typeof value === "boolean" ? value : false} onChange={onChange} />;
}
if (field.type === "textarea") {
return <label className="theme-option"><span>{field.label}</span><textarea rows={3} maxLength={500} placeholder={field.placeholder} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)} /></label>;
}
if (field.type === "select") {
return <label className="theme-option"><span>{field.label}</span><select value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>{field.options?.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>;
}
if (field.type === "number") {
return <label className="theme-option"><span>{field.label}</span><input type="number" min={field.min} max={field.max} step={field.step} value={typeof value === "number" ? value : ""} onChange={(event) => onChange(Number(event.target.value))} /></label>;
}
return <label className={`theme-option ${field.type === "color" ? "theme-color-option" : ""}`}><span>{field.label}</span><input type={field.type} maxLength={field.type === "color" ? undefined : 500} placeholder={field.placeholder} value={typeof value === "string" ? value : field.type === "color" ? "#0f766e" : ""} onChange={(event) => onChange(event.target.value)} /></label>;
}