function toast(msg) { const el = document.getElementById("toast"); el.textContent = msg; el.classList.remove("hidden"); setTimeout(() => el.classList.add("hidden"), 2200); } function panelBase() { const fromBody = (document.body.dataset.base || "").replace(/\/$/, ""); if (fromBody) return fromBody; const parts = location.pathname.split("/").filter(Boolean); if (parts.length && parts[0].startsWith("jiedian-")) { return `/${parts[0]}`; } return ""; } function apiUrl(path) { return `${panelBase()}${path}`; } function copyText(text) { if (navigator.clipboard && window.isSecureContext) { return navigator.clipboard.writeText(text); } return new Promise((resolve, reject) => { const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); try { if (document.execCommand("copy")) { resolve(); } else { reject(new Error("execCommand failed")); } } catch (err) { reject(err); } finally { document.body.removeChild(ta); } }); } let nodeLinksCache = null; async function fetchNodeLinks() { if (nodeLinksCache) { return nodeLinksCache; } const res = await fetch(apiUrl("/api/nodes"), { credentials: "same-origin" }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } nodeLinksCache = await res.json(); return nodeLinksCache; } document.querySelectorAll(".copy-row").forEach((row) => { const input = row.querySelector(".copy-input"); const btn = row.querySelector(".copy-btn"); if (!input || !btn) return; input.addEventListener("click", () => { input.select(); input.setSelectionRange(0, input.value.length); }); btn.addEventListener("click", async () => { const card = row.closest(".node-card"); const nodeId = card?.dataset.id; const kind = btn.dataset.linkKind || input.dataset.linkKind || "vless"; let text = input.value; if (nodeId) { try { const nodes = await fetchNodeLinks(); const node = nodes.find((item) => String(item.id) === String(nodeId)); if (node?.links) { text = kind === "hy2" ? node.links.hy2 : node.links.vless; } } catch { /* fall back to input.value */ } } if (!text) { toast("没有可复制的内容"); return; } try { await copyText(text); toast("已复制到剪贴板"); } catch { input.select(); input.setSelectionRange(0, text.length); toast("复制失败,请选中上方链接后 Ctrl+C"); } }); }); const modal = document.getElementById("modal"); const addBtn = document.getElementById("addBtn"); const cancelBtn = document.getElementById("cancelBtn"); const confirmAddBtn = document.getElementById("confirmAddBtn"); const nodeName = document.getElementById("nodeName"); if (addBtn) { addBtn.addEventListener("click", () => { nodeName.value = ""; modal.classList.remove("hidden"); nodeName.focus(); }); } if (cancelBtn) { cancelBtn.addEventListener("click", () => modal.classList.add("hidden")); } function setButtonBusy(btn, busy, busyText) { if (!btn) return; if (busy) { if (!btn.dataset.label) btn.dataset.label = btn.textContent; btn.textContent = busyText; btn.disabled = true; } else { btn.textContent = btn.dataset.label || btn.textContent; btn.disabled = false; } } async function readJson(res) { const text = await res.text(); try { return JSON.parse(text); } catch { throw new Error(res.ok ? "响应格式错误" : `HTTP ${res.status}`); } } async function reloadPanel(maxWaitMs = 15000) { const url = `${panelBase()}/`; const started = Date.now(); while (Date.now() - started < maxWaitMs) { await new Promise((r) => setTimeout(r, 1500)); try { const res = await fetch(url, { credentials: "same-origin" }); if (res.ok) { location.href = url; return; } } catch { /* sing-box 重启期间可能短暂不可用,继续重试 */ } } location.href = url; } if (confirmAddBtn) { confirmAddBtn.addEventListener("click", async () => { const name = nodeName.value.trim() || "新节点"; setButtonBusy(confirmAddBtn, true, "创建中…"); if (cancelBtn) cancelBtn.disabled = true; try { const res = await fetch(apiUrl("/api/nodes"), { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); const data = await readJson(res); if (!res.ok) throw new Error(data.error || "创建失败"); toast("节点已创建,配置生效中…"); modal.classList.add("hidden"); reloadPanel(); } catch (err) { toast(err.message || "创建失败"); setButtonBusy(confirmAddBtn, false); if (cancelBtn) cancelBtn.disabled = false; } }); } document.querySelectorAll(".delete-btn").forEach((btn) => { btn.addEventListener("click", async () => { const id = btn.dataset.id; if (!confirm("确定删除该节点?删除后对应链接将失效。")) return; setButtonBusy(btn, true, "删除中…"); try { const res = await fetch(apiUrl(`/api/nodes/${id}`), { method: "DELETE", credentials: "same-origin", }); const data = await readJson(res); if (!res.ok) throw new Error(data.error || "删除失败"); toast("已删除,配置生效中…"); reloadPanel(); } catch (err) { toast(err.message || "删除失败"); setButtonBusy(btn, false); } }); }); function setStatusBadge(el, online) { el.textContent = online ? "在线" : "离线"; el.classList.toggle("online", online); el.classList.toggle("offline", !online); } function updateStats(data) { const summary = data.summary || {}; const onlineEl = document.getElementById("summaryOnline"); const upEl = document.getElementById("summaryUp"); const downEl = document.getElementById("summaryDown"); const statusEl = document.getElementById("summaryStatus"); if (onlineEl) { onlineEl.textContent = `${summary.online || 0} / ${summary.total_nodes || 0}`; } if (upEl) upEl.textContent = summary.upload_speed_human || "0 B/s"; if (downEl) downEl.textContent = summary.download_speed_human || "0 B/s"; if (statusEl) { if (data.singbox) { statusEl.textContent = "正常"; statusEl.className = "status-text ok"; } else { statusEl.textContent = "不可用"; statusEl.className = "status-text err"; } } document.querySelectorAll(".node-card[data-id]").forEach((card) => { const node = (data.nodes || {})[card.dataset.id]; if (!node) return; const status = card.querySelector('[data-role="status"]'); if (status) setStatusBadge(status, node.online); const setText = (role, value) => { const el = card.querySelector(`[data-role="${role}"]`); if (el) el.textContent = value; }; setText("connections", String(node.connections ?? 0)); setText("speed-up", node.upload_speed_human || "0 B/s"); setText("speed-down", node.download_speed_human || "0 B/s"); setText("total-up", node.upload_total_human || "0 B"); setText("total-down", node.download_total_human || "0 B"); }); } async function refreshStats() { try { const res = await fetch(apiUrl("/api/stats"), { credentials: "same-origin" }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const data = await res.json(); updateStats(data); } catch (err) { const statusEl = document.getElementById("summaryStatus"); if (statusEl) { statusEl.textContent = "不可用"; statusEl.className = "status-text err"; } document.querySelectorAll('[data-role="status"]').forEach((el) => { el.textContent = "未知"; el.classList.add("offline"); }); console.error("stats refresh failed:", err); } } if (document.getElementById("summaryBar")) { refreshStats(); setInterval(refreshStats, 5000); }