d75193d527
Always inject panel_base from PANEL_PATH for static/API URLs and set SCRIPT_NAME in middleware so /api/stats works behind nginx subpaths. Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
5.9 KiB
JavaScript
205 lines
5.9 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
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 text = input.value;
|
|
if (!text) {
|
|
toast("没有可复制的内容");
|
|
return;
|
|
}
|
|
try {
|
|
input.select();
|
|
input.setSelectionRange(0, text.length);
|
|
await copyText(text);
|
|
toast("已复制到剪贴板");
|
|
} catch {
|
|
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"));
|
|
}
|
|
|
|
if (confirmAddBtn) {
|
|
confirmAddBtn.addEventListener("click", async () => {
|
|
const name = nodeName.value.trim() || "新节点";
|
|
confirmAddBtn.disabled = true;
|
|
try {
|
|
const res = await fetch(apiUrl("/api/nodes"), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "创建失败");
|
|
location.reload();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
} finally {
|
|
confirmAddBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll(".delete-btn").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const id = btn.dataset.id;
|
|
if (!confirm("确定删除该节点?删除后对应链接将失效。")) return;
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch(apiUrl(`/api/nodes/${id}`), { method: "DELETE" });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "删除失败");
|
|
location.reload();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
btn.disabled = 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);
|
|
}
|