Files
NodeFlare/frontend/src/components/AdminPanel.tsx
T
2026-08-11 22:50:34 +08:00

673 lines
49 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,
ArrowLeft,
ChevronDown,
ChevronUp,
Cloud,
Coins,
Copy,
Database,
Eye,
GripVertical,
KeyRound,
LogOut,
Moon,
Palette,
Pencil,
Plus,
RadioTower,
RotateCw,
Save,
ServerCog,
ShieldCheck,
SlidersHorizontal,
Sun,
Trash2,
Check,
ExternalLink,
} from "lucide-react";
import { DragEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { api, ApiError, getToken, setToken } from "../api";
import { formatBytes, isOnline } from "../format";
import { derivePassword } from "../password";
import { ASSET_CURRENCIES, type AdminServer, type AgentInstallTarget, 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";
import { TurnstileWidget } from "./TurnstileWidget";
import { LatencyManager } from "./LatencyManager";
import { AlertRuleManager } from "./AlertRuleManager";
type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data";
type AgentPlatform = "linux" | "windows" | "macos";
const adminPages: Record<AdminTab, { title: string; description: string }> = {
servers: { title: "服务器", description: "管理监控节点、Agent 密钥和运行参数" },
latency: { title: "延迟监测", description: "配置分配给各服务器的 TCP 与 ICMP 延迟任务" },
appearance: { title: "站点设置", description: "调整站点信息、公开内容和前台显示项目" },
themes: { title: "主题商店", description: "选择内置主题或安装远程主题" },
themeSettings: { title: "主题设置", description: "调整当前前端主题提供的显示选项" },
alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" },
security: { title: "登录与安全", description: "管理管理员账号和 Cloudflare Turnstile 防护" },
data: { title: "监控数据库", description: "查看 D1 用量、汇率状态并维护历史数据" },
};
const emptyServer: ServerInput = {
name: "",
region: "",
group_name: "默认",
tags: "",
note: "",
hidden: false,
expires_at: null,
traffic_limit: 0,
traffic_limit_type: "sum",
price: 0,
billing_cycle: 30,
currency: "CNY",
auto_renewal: false,
public_remark: "",
network_interface: "",
reset_day: 1,
report_interval: 60,
collect_interval: 5,
rx_correction: 0,
tx_correction: 0,
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,
note: server.note,
hidden: !!server.hidden,
expires_at: server.expires_at,
traffic_limit: server.traffic_limit,
traffic_limit_type: server.traffic_limit_type || "sum",
price: server.price,
billing_cycle: server.billing_cycle,
currency: server.currency,
auto_renewal: !!server.auto_renewal,
public_remark: server.public_remark,
network_interface: server.network_interface || "",
reset_day: server.reset_day || 1,
report_interval: server.report_interval || 60,
collect_interval: server.collect_interval || 5,
rx_correction: server.rx_correction || 0,
tx_correction: server.tx_correction || 0,
offline_notify_disabled: !!server.offline_notify_disabled,
auto_update: !!server.auto_update,
};
}
function formatDate(value: number | null) {
return value ? new Date(value * 1000).toISOString().slice(0, 10) : "";
}
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("'", "''")}'`;
}
export function AdminPanel({
config,
dark,
onToggleTheme,
onClose,
onChanged,
}: {
config: Config;
dark: boolean;
onToggleTheme: () => void;
onClose: () => void;
onChanged: () => void;
}) {
const [authenticated, setAuthenticated] = useState(!!getToken());
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileReset, setTurnstileReset] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [tab, setTab] = useState<AdminTab>("servers");
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 [cloudflareUsage, setCloudflareUsage] = useState<CloudflareUsage | null>(null);
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
const [themes, setThemes] = useState<Theme[]>([]);
const [themeName, setThemeName] = useState("");
const [themeDescription, setThemeDescription] = useState("");
const [themeUrl, setThemeUrl] = useState("");
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<AgentInstallTarget[]>([]);
const [installPlatform, setInstallPlatform] = useState<AgentPlatform>("linux");
const load = useCallback(async () => {
if (!getToken()) return;
setBusy(true);
setError("");
try {
const [serverResult, settingsResult, themesResult] = await Promise.all([api.adminServers(), api.settings(), api.themes()]);
setServers(serverResult.servers);
setThemes(themesResult.themes);
setSettings({
...settingsResult,
site_announcement: settingsResult.site_announcement || "",
theme_options: settingsResult.theme_options || {},
cloudflare_account_id: settingsResult.cloudflare_account_id || "",
cloudflare_api_token: settingsResult.cloudflare_api_token || "",
favicon_url: settingsResult.favicon_url || "",
locale: settingsResult.locale || "zh-CN",
});
setAuthenticated(true);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "加载失败");
if (reason instanceof ApiError && reason.status === 401) {
setToken("");
setAuthenticated(false);
}
} finally {
setBusy(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
if (!notice) return;
const timer = window.setTimeout(() => setNotice(""), 3200);
return () => window.clearTimeout(timer);
}, [notice]);
async function login(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
const passwordDerived = await derivePassword(password, config.password_client_salt);
const result = await api.login(username.trim(), password, passwordDerived, turnstileToken);
setToken(result.token);
setPassword("");
setTurnstileToken("");
setAuthenticated(true);
await load();
} catch (reason) {
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 });
setError("");
}
async function saveServer(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
if (editing === "new") {
const result = await api.createServer(form);
setInstall([{ id: result.id, name: form.name, agent_token: result.agent_token }]);
} else if (editing) {
await api.updateServer(editing.id, form);
}
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 rotate(server: AdminServer) {
if (!window.confirm(`重置“${server.name}”的 Agent 密钥?旧 Agent 将立即失效。`)) return;
try {
const result = await api.rotateToken(server.id);
setInstall([{ id: server.id, name: server.name, agent_token: result.agent_token }]);
} catch (reason) { setError(reason instanceof Error ? reason.message : "重置失败"); }
}
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;
try {
if (payload.new_password) {
payload.new_password_derived = await derivePassword(payload.new_password, config.password_client_salt);
} else {
delete payload.new_password;
delete payload.new_password_derived;
}
const result = await api.saveSettings(payload);
if (result.token) setToken(result.token);
setSettings(result.settings);
setNotice("设置已保存");
onChanged();
} catch (reason) { setError(reason instanceof Error ? reason.message : "保存设置失败"); }
finally { setBusy(false); }
}
async function testNotification() {
setBusy(true); setError(""); setNotice("");
try { await api.testNotification(); setNotice("测试通知已发送"); }
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);
setExchangeRates(rates);
}
catch (reason) { setError(reason instanceof Error ? reason.message : "读取数据库统计失败"); }
finally { setBusy(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 loadCloudflareUsage() {
setBusy(true); setError("");
try { setCloudflareUsage(await api.cloudflareUsage()); }
catch (reason) { setError(reason instanceof Error ? reason.message : "读取 Cloudflare 用量失败"); }
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();
setBusy(true); setError(""); setNotice("");
try {
await api.addTheme({ name: themeName.trim(), description: themeDescription.trim(), url: themeUrl.trim() });
setThemeName(""); setThemeDescription(""); setThemeUrl("");
await load();
setNotice("主题已添加");
} 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 clearHistory() {
if (!window.confirm("确认清空全部历史指标?节点配置和最新状态不会删除。")) return;
setBusy(true); setError("");
try { await api.clearHistory(); setNotice("历史指标已清空"); await loadDatabase(); }
catch (reason) { setError(reason instanceof Error ? reason.message : "清理历史失败"); }
finally { setBusy(false); }
}
function logout() {
setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
}
const commands = useMemo(() => install.map((server) => {
const origin = window.location.origin;
const release = "https://github.com/imengying/NodeFlare/releases/latest/download";
if (installPlatform === "windows") {
return {
...server,
command: `Invoke-WebRequest -Uri "${release}/install-agent.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" install -ServerId ${powershellLiteral(server.id)} -Token ${powershellLiteral(server.agent_token)} -Url ${powershellLiteral(origin)}`,
};
}
if (installPlatform === "macos") {
return {
...server,
command: `curl -fsSL ${release}/install-agent-macos.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`,
};
}
return {
...server,
command: `curl -fsSL ${release}/install-agent.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`,
};
}), [install, installPlatform]);
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) => {
setTab(next);
setNotice("");
setError("");
};
return (
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
{!authenticated ? <div className="admin-login-stage">
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={14} />返回仪表盘</button>
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={16} /> : <Moon size={16} />}</button>
<form className="login-form glass-panel" onSubmit={login}>
<img src="/logo.svg" 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 /></label>
<label><span>密码</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
{config.turnstile_login_enabled || config.turnstile_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
{error ? <p className="form-error login-error">{error}</p> : null}
<button className="primary-btn login-submit" disabled={busy || ((config.turnstile_login_enabled || config.turnstile_enabled) && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? "验证中" : "登录"}</button>
</form>
</div> : <section className="admin-shell" aria-label="管理面板">
<header className="admin-topbar">
<div className="admin-brand">
<img src="/logo.svg" alt="" width="36" height="36" />
<strong>{config.site_name}</strong>
</div>
<div className="admin-topbar-actions">
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button>
<button type="button" onClick={onClose}><ArrowLeft size={17} />返回前台</button>
<button type="button" onClick={logout}><LogOut size={17} />退出登录</button>
</div>
</header>
<div className="admin-body">
<aside className="admin-sidebar">
<span className="admin-nav-label">管理</span>
<nav className="admin-tabs">
<button className={tab === "servers" ? "active" : ""} onClick={() => selectTab("servers")}><ServerCog size={19} />服务器</button>
<button className={tab === "latency" ? "active" : ""} onClick={() => selectTab("latency")}><RadioTower size={19} />延迟监测</button>
<button className={tab === "appearance" ? "active" : ""} onClick={() => selectTab("appearance")}><Eye size={19} />站点设置</button>
<button className={tab === "themes" ? "active" : ""} onClick={() => selectTab("themes")}><Palette size={19} />主题商店</button>
<button className={tab === "themeSettings" ? "active" : ""} onClick={() => { selectTab("themeSettings"); void loadThemeSettings(); }}><SlidersHorizontal size={19} />主题设置</button>
<button className={tab === "alerts" ? "active" : ""} onClick={() => selectTab("alerts")}><AlertTriangle size={19} />通知</button>
<button className={tab === "security" ? "active" : ""} onClick={() => selectTab("security")}><ShieldCheck size={19} />登录与安全</button>
<button className={tab === "data" ? "active" : ""} onClick={() => { selectTab("data"); if (!database) void loadDatabase(); }}><Database size={19} />监控数据库</button>
</nav>
</aside>
<div className="admin-content">
<header className="admin-content-header"><h1>{adminPages[tab].title}</h1><p>{adminPages[tab].description}</p></header>
{error ? <p className="form-error admin-error">{error}</p> : null}
{notice ? <p className="form-notice">{notice}</p> : null}
{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={16} />添加</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={14} />删除选中 ({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"><strong>{server.name}</strong><small>{server.group_name} · {server.region || "未设置地区"} · {server.report_interval}s</small></div>
<span className="server-usage">{server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "不限流量"}</span>
<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" onClick={() => openEditor(server)} title="编辑节点"><Pencil size={15} /></button><button className="icon-btn" onClick={() => void rotate(server)} title="重置 Agent 密钥"><RotateCw 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"><div><h3>主题列表</h3><span>内置主题始终可用,远程主题由 Worker 代理前台页面和资源。</span></div></div>
<div className="theme-store-grid">
{themes.map((theme) => <article className={`theme-store-card ${theme.active ? "active" : ""}`} key={theme.id}>
<div className="theme-card-icon"><Palette size={20} /></div>
<div className="theme-card-main">
<div className="theme-card-title"><strong>{theme.name}</strong><span className={`theme-badge ${theme.builtin ? "builtin" : "remote"}`}>{theme.builtin ? "默认主题" : "远程主题"}</span></div>
<p>{theme.description || "暂无主题说明"}</p>
{!theme.builtin ? <a href={theme.url} target="_blank" rel="noreferrer"><span>{theme.url}</span><ExternalLink size={13} /></a> : <small>NodeFlare 内置 Komari Glass</small>}
</div>
<div className="theme-card-actions">
{!theme.builtin ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void previewTheme(theme)}><Eye size={14} />预览</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={14} />使用中</> : "启用"}</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-title"><Plus size={15} />添加远程主题</div>
<p className="settings-hint">填写 GitHub <code>tree</code> 地址,对应目录需包含 <code>index.html</code> 和 <code>assets/</code> 目录。</p>
<div className="form-grid"><label><span>主题名称</span><input required maxLength={80} value={themeName} onChange={(event) => setThemeName(event.target.value)} placeholder="例如:Ocean" /></label><label><span>主题 URL</span><input required type="url" maxLength={2048} value={themeUrl} onChange={(event) => setThemeUrl(event.target.value)} placeholder="https://github.com/user/theme/tree/main" /></label></div>
<label><span>主题说明(可选)</span><textarea rows={2} maxLength={300} value={themeDescription} onChange={(event) => setThemeDescription(event.target.value)} placeholder="简短描述主题风格和来源" /></label>
<div className="theme-risk-note">远程主题包含第三方 HTML、JavaScript 和 CSS。仅添加你信任的主题仓库。</div>
<div className="form-actions"><button className="primary-btn" disabled={busy}><Plus size={16} />添加主题</button></div>
</form>
<form className="admin-section" onSubmit={saveSite}>
<div className="section-title"><Eye size={15} />公开访问</div>
<Toggle label="公开仪表盘" checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} />
<p className="settings-hint">关闭后,未登录访客无法读取节点和历史数据。</p>
<div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存公开设置</button></div>
</form>
</div>
) : settings ? (
<form className="settings-form" onSubmit={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>站点图标地址</span><input type="url" value={settings.favicon_url} onChange={(event) => updateSettings("favicon_url", event.target.value)} placeholder="https://example.com/favicon.png" /></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="365" 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" value={settings.background_url} onChange={(event) => updateSettings("background_url", event.target.value)} /></label></div>
<p className="settings-hint">背景图可用 | 分隔浅色和深色地址,例如 light.jpg|dark.jpg。</p>
{themeSettingsSchema?.settings.length ? <><div className="section-subtitle">当前主题选项</div><div className="theme-option-grid">{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="theme-option-empty">当前主题没有提供额外设置</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>
<div className="form-grid"><label><span>Telegram Bot Token</span><input autoComplete="off" type="password" value={settings.notification_endpoint} onChange={(event) => updateSettings("notification_endpoint", event.target.value)} placeholder="123456789:AA..." /></label><label><span>Telegram Chat ID</span><input value={settings.notification_target} onChange={(event) => updateSettings("notification_target", event.target.value)} placeholder="个人、群组或频道 ID" /></label></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><button type="button" className="secondary-btn" disabled={busy} onClick={() => void testNotification()}>发送测试通知</button></label></div>
<p className="settings-hint">通过 Telegram Bot 发送告警;告警状态会记录在 D1,同一故障和恢复只发送一次。</p>
<AlertRuleManager servers={servers} onError={setError} onNotice={setNotice} />
</> : null}
{tab === "security" ? <>
<div className="section-title"><ShieldCheck size={15} />账号与 Cloudflare 防护</div>
<div className="form-grid"><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" value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label></div>
<div className="security-note">当前密码状态:{settings.admin_password_configured ? "已使用 D1 加盐哈希" : "使用 ADMIN_PASSWORD 初始化密钥"}</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 value={settings.turnstile_site_key} onChange={(event) => updateSettings("turnstile_site_key", event.target.value)} /></label><label><span>Turnstile Secret Key</span><input type="password" value={settings.turnstile_secret_key} onChange={(event) => updateSettings("turnstile_secret_key", event.target.value)} /></label></div>
<p className="settings-hint">留空时读取 Worker 的 TURNSTILE_SITE_KEY 和 TURNSTILE_SECRET_KEY,后台配置优先。</p>
</> : null}
{tab === "data" ? <>
<div className="section-title"><Database size={15} />D1 数据维护</div>
<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={14} />立即更新</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>}
</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={14} />{cloudflareUsage ? "刷新用量" : "查询用量"}</button></div>
<div className="form-grid"><label><span>Cloudflare Account ID</span><input autoComplete="off" maxLength={32} value={settings.cloudflare_account_id} onChange={(event) => updateSettings("cloudflare_account_id", event.target.value)} placeholder="32 位账户 ID" /></label><label><span>Cloudflare API Token</span><input autoComplete="off" type="password" value={settings.cloudflare_api_token} onChange={(event) => updateSettings("cloudflare_api_token", event.target.value)} placeholder="需要 Account Analytics Read 权限" /></label></div>
<div className="usage-config-actions"><p className="settings-hint">留空时读取 Worker Secret 中的同名变量。</p><button type="submit" className="primary-btn compact" disabled={busy}><Save size={14} />保存用量配置</button></div>
{cloudflareUsage ? <div className="usage-table-wrap"><table className="usage-table"><thead><tr><th>周期</th><th>D1 读取</th><th>D1 写入</th><th>Workers 请求</th></tr></thead><tbody><UsageRow label="今日" usage={cloudflareUsage.today} /><UsageRow label="昨日" usage={cloudflareUsage.yesterday} /></tbody></table></div> : <div className="usage-empty">尚未读取</div>}
</div>
<div className="data-actions"><button type="button" className="secondary-btn" onClick={() => void loadDatabase()}>刷新统计</button><button type="button" className="danger-btn" onClick={() => void clearHistory()}><Trash2 size={15} />清空历史指标</button></div>
<p className="settings-hint">清空历史不会删除节点、密钥或最新状态。</p>
</> : null}
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
</form>
) : null}
</div>
</div>
</section>}
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><form className="editor-modal glass-panel" onSubmit={saveServer} onMouseDown={(event) => event.stopPropagation()}>
<header><div><span className="eyebrow">节点配置</span><h3>{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>到期日期</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="2" 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={form.rx_correction / 1024 ** 3} onChange={(event) => updateForm("rx_correction", Number(event.target.value) * 1024 ** 3)} /></label><label><span>上行流量修正(GB)</span><input min="0" step="0.1" type="number" value={form.tx_correction / 1024 ** 3} onChange={(event) => updateForm("tx_correction", Number(event.target.value) * 1024 ** 3)} /></label></div>
<label><span>公开备注</span><textarea rows={2} value={form.public_remark} onChange={(event) => updateForm("public_remark", event.target.value)} /></label><label><span>管理备注</span><textarea rows={2} value={form.note} onChange={(event) => updateForm("note", event.target.value)} /></label>
<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={16} />保存节点</button></div>
</form></div> : null}
{commands.length ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setInstall([])}><section className="install-modal glass-panel" onMouseDown={(event) => event.stopPropagation()}><header><div><span className="eyebrow">Agent 部署</span><h3>安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button></div></header><div className="install-list">{commands.map((item) => <div key={item.id}><strong>{item.name}</strong><pre>{item.command}</pre></div>)}</div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall([])}>完成</button><button className="primary-btn" type="button" onClick={() => navigator.clipboard.writeText(commands.map((item) => `# ${item.name}\n${item.command}`).join("\n\n"))}><Copy size={16} />复制全部</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>;
}
function DataStat({ label, value }: { label: string; value: number | string }) {
return <div className="data-stat"><span>{label}</span><strong>{value}</strong></div>;
}
function UsageRow({ label, usage }: { label: string; usage: CloudflareUsage["today"] }) {
return <tr><th scope="row">{label}</th><td>{usage.rows_read.toLocaleString()}</td><td>{usage.rows_written.toLocaleString()}</td><td>{usage.workers_requests.toLocaleString()}</td></tr>;
}