Initial stun navigation app

This commit is contained in:
zeer
2026-07-12 16:22:32 +08:00
commit 8570eb84da
12 changed files with 2086 additions and 0 deletions

314
public/admin.js Normal file
View File

@@ -0,0 +1,314 @@
const tokenInput = document.querySelector("#tokenInput");
const statusBar = document.querySelector("#statusBar");
const refreshButton = document.querySelector("#refreshButton");
const form = document.querySelector("#serviceForm");
const settingsForm = document.querySelector("#settingsForm");
const rowsEl = document.querySelector("#serviceRows");
const adminBrand = document.querySelector("#adminBrand");
const browserTitleInput = document.querySelector("#browserTitleInput");
const brandInput = document.querySelector("#brandInput");
const titleInput = document.querySelector("#titleInput");
const subtitleInput = document.querySelector("#subtitleInput");
const originalIdInput = document.querySelector("#originalIdInput");
const idInput = document.querySelector("#idInput");
const nameInput = document.querySelector("#nameInput");
const groupInput = document.querySelector("#groupInput");
const urlInput = document.querySelector("#urlInput");
const noteInput = document.querySelector("#noteInput");
const resetButton = document.querySelector("#resetButton");
const serviceFormTitle = document.querySelector("#serviceFormTitle");
const newFromListButton = document.querySelector("#newFromListButton");
const tabButtons = document.querySelectorAll(".tab-button");
const quickActionButtons = document.querySelectorAll(".quick-action");
const adminSections = document.querySelectorAll(".admin-section");
let services = [];
let settings = {};
function setStatus(message, isError = false) {
statusBar.textContent = message;
statusBar.classList.toggle("error", isError);
}
function showPanel(panelId) {
for (const button of tabButtons) {
button.classList.toggle("active", button.dataset.target === panelId);
}
for (const button of quickActionButtons) {
button.classList.toggle("active", button.dataset.target === panelId);
}
for (const section of adminSections) {
section.classList.toggle("active", section.dataset.panel === panelId);
}
}
function formatTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function getToken() {
return tokenInput.value.trim();
}
function apiUrl(path) {
const url = new URL(path, window.location.origin);
const token = getToken();
if (token) url.searchParams.set("token", token);
return url;
}
function loadSavedToken() {
tokenInput.value = localStorage.getItem("stun-nav-admin-token") || "";
}
function saveToken() {
localStorage.setItem("stun-nav-admin-token", getToken());
}
function applySettings(nextSettings = {}) {
settings = nextSettings;
document.title = settings.browserTitle ? `${settings.browserTitle} - 管理` : "导航管理";
adminBrand.textContent = settings.brand || "STUN NAV";
browserTitleInput.value = settings.browserTitle || "";
brandInput.value = settings.brand || "";
titleInput.value = settings.title || "";
subtitleInput.value = settings.subtitle || "";
}
function clearForm() {
originalIdInput.value = "";
form.reset();
tokenInput.value = localStorage.getItem("stun-nav-admin-token") || tokenInput.value;
serviceFormTitle.textContent = "新增导航";
showPanel("servicePanel");
idInput.focus();
}
function fillForm(service) {
originalIdInput.value = service.id;
idInput.value = service.id;
nameInput.value = service.name || "";
groupInput.value = service.group || "";
urlInput.value = service.url || "";
noteInput.value = service.note || "";
serviceFormTitle.textContent = "编辑导航";
showPanel("servicePanel");
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"content-type": "application/json",
...(options.headers || {})
}
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error(data.error || `请求失败:${response.status}`);
}
return data;
}
function renderRows() {
rowsEl.replaceChildren();
if (!services.length) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 6;
cell.className = "empty-cell";
cell.textContent = "还没有导航项";
row.append(cell);
rowsEl.append(row);
return;
}
for (const service of services) {
const row = document.createElement("tr");
row.innerHTML = `
<td class="mono"></td>
<td></td>
<td></td>
<td class="url-cell"></td>
<td></td>
<td></td>
`;
const cells = row.querySelectorAll("td");
cells[0].textContent = service.id;
cells[1].textContent = service.name || "";
cells[2].textContent = service.group || "";
cells[3].textContent = service.url || "";
cells[4].textContent = formatTime(service.updatedAt);
const actions = document.createElement("div");
actions.className = "row-actions";
const editButton = document.createElement("button");
editButton.type = "button";
editButton.className = "small-button";
editButton.textContent = "编辑";
editButton.addEventListener("click", () => fillForm(service));
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "small-button danger-button";
deleteButton.textContent = "删除";
deleteButton.addEventListener("click", () => deleteService(service));
actions.append(editButton, deleteButton);
cells[5].append(actions);
rowsEl.append(row);
}
}
async function loadServices() {
try {
const data = await requestJson("/api/services", { method: "GET" });
services = Array.isArray(data.services) ? data.services : [];
applySettings(data.settings || {});
renderRows();
setStatus(`已读取 ${services.length} 个导航项`);
} catch (error) {
setStatus(error.message || "读取失败", true);
}
}
async function deleteService(service) {
if (!getToken()) {
setStatus("请先填写管理密钥", true);
tokenInput.focus();
return;
}
const confirmed = window.confirm(`确定删除「${service.name || service.id}」吗?`);
if (!confirmed) return;
try {
saveToken();
await requestJson(apiUrl(`/api/services/${encodeURIComponent(service.id)}`), {
method: "DELETE"
});
if (originalIdInput.value === service.id) clearForm();
await loadServices();
setStatus(`已删除 ${service.name || service.id}`);
} catch (error) {
setStatus(error.message || "删除失败", true);
}
}
async function saveService(event) {
event.preventDefault();
if (!getToken()) {
setStatus("请先填写管理密钥", true);
tokenInput.focus();
return;
}
const id = idInput.value.trim();
const originalId = originalIdInput.value.trim();
if (!id) {
setStatus("请填写 ID", true);
return;
}
const payload = {
id,
name: nameInput.value.trim(),
group: groupInput.value.trim() || "默认",
url: urlInput.value.trim(),
note: noteInput.value.trim()
};
try {
saveToken();
await requestJson(apiUrl(`/api/webhook/${encodeURIComponent(id)}`), {
method: "POST",
body: JSON.stringify(payload)
});
if (originalId && originalId !== id) {
await requestJson(apiUrl(`/api/services/${encodeURIComponent(originalId)}`), {
method: "DELETE"
});
}
originalIdInput.value = id;
await loadServices();
setStatus(`已保存 ${payload.name || id}`);
clearForm();
showPanel("listPanel");
} catch (error) {
setStatus(error.message || "保存失败", true);
}
}
async function saveSettings(event) {
event.preventDefault();
if (!getToken()) {
setStatus("请先填写管理密钥", true);
tokenInput.focus();
return;
}
const payload = {
browserTitle: browserTitleInput.value.trim(),
brand: brandInput.value.trim(),
title: titleInput.value.trim(),
subtitle: subtitleInput.value.trim()
};
try {
saveToken();
const data = await requestJson(apiUrl("/api/settings"), {
method: "PUT",
body: JSON.stringify(payload)
});
applySettings(data.settings || payload);
setStatus("页面设置已保存");
} catch (error) {
setStatus(error.message || "保存页面设置失败", true);
}
}
loadSavedToken();
loadServices();
tokenInput.addEventListener("change", saveToken);
refreshButton.addEventListener("click", loadServices);
resetButton.addEventListener("click", clearForm);
newFromListButton.addEventListener("click", clearForm);
form.addEventListener("submit", saveService);
settingsForm.addEventListener("submit", saveSettings);
for (const button of tabButtons) {
button.addEventListener("click", () => {
if (button.dataset.mode === "new") {
clearForm();
return;
}
showPanel(button.dataset.target);
});
}
for (const button of quickActionButtons) {
button.addEventListener("click", () => {
if (button.dataset.mode === "new") {
clearForm();
return;
}
showPanel(button.dataset.target);
});
}