Initial stun navigation app
This commit is contained in:
138
public/admin.html
Normal file
138
public/admin.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>导航管理</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=6" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell admin-shell">
|
||||
<header class="topbar hero compact-hero">
|
||||
<div class="hero-copy">
|
||||
<p id="adminBrand" class="eyebrow">STUN NAV</p>
|
||||
<h1>导航管理</h1>
|
||||
<p class="subtitle">调整首页展示内容,也可以手动维护导航入口</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="quick-action primary-button compact-button" type="button" data-target="servicePanel" data-mode="new">新增导航</button>
|
||||
<button class="quick-action ghost-button compact-button" type="button" data-target="settingsPanel">页面设置</button>
|
||||
<a class="text-link" href="/">返回导航</a>
|
||||
<button id="refreshButton" class="icon-button" type="button" title="刷新">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 0 1-15.18 6.57M3 12A9 9 0 0 1 18.18 5.43M21 5v6h-6M3 19v-6h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="admin-console">
|
||||
<aside class="admin-sidebar">
|
||||
<label class="field token-field">
|
||||
<span>管理密钥</span>
|
||||
<input id="tokenInput" type="password" autocomplete="off" placeholder="填写 WEBHOOK_TOKEN" />
|
||||
</label>
|
||||
<nav class="admin-tabs" aria-label="后台栏目">
|
||||
<button class="tab-button active" type="button" data-target="settingsPanel">页面设置</button>
|
||||
<button class="tab-button" type="button" data-target="servicePanel" data-mode="new">新增导航</button>
|
||||
<button class="tab-button" type="button" data-target="listPanel">导航列表</button>
|
||||
</nav>
|
||||
<section id="statusBar" class="status admin-status" aria-live="polite">正在读取导航...</section>
|
||||
</aside>
|
||||
|
||||
<section class="admin-workspace">
|
||||
<form id="settingsForm" class="admin-panel settings-form admin-section active" data-panel="settingsPanel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="panel-kicker">DISPLAY</p>
|
||||
<h2>页面设置</h2>
|
||||
</div>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>浏览器标题</span>
|
||||
<input id="browserTitleInput" name="browserTitle" autocomplete="off" placeholder="家中服务导航" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>顶部标识</span>
|
||||
<input id="brandInput" name="brand" autocomplete="off" placeholder="STUN NAV" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>首页标题</span>
|
||||
<input id="titleInput" name="title" autocomplete="off" placeholder="家中服务" />
|
||||
</label>
|
||||
<label class="field full">
|
||||
<span>首页副标题</span>
|
||||
<input id="subtitleInput" name="subtitle" autocomplete="off" placeholder="聚合 Lucky STUN 最新入口,一处打开所有家中应用" />
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="primary-button" type="submit">保存页面设置</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="serviceForm" class="admin-panel service-form admin-section" data-panel="servicePanel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="panel-kicker">SERVICE</p>
|
||||
<h2 id="serviceFormTitle">新增导航</h2>
|
||||
</div>
|
||||
</div>
|
||||
<input id="originalIdInput" type="hidden" />
|
||||
<label class="field">
|
||||
<span>ID</span>
|
||||
<input id="idInput" name="id" autocomplete="off" required placeholder="alist" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>名称</span>
|
||||
<input id="nameInput" name="name" autocomplete="off" required placeholder="AList" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>分组</span>
|
||||
<input id="groupInput" name="group" autocomplete="off" placeholder="文件" />
|
||||
</label>
|
||||
<label class="field full">
|
||||
<span>地址</span>
|
||||
<input id="urlInput" name="url" autocomplete="off" required placeholder="http://1.2.3.4:12345" />
|
||||
</label>
|
||||
<label class="field full">
|
||||
<span>备注</span>
|
||||
<input id="noteInput" name="note" autocomplete="off" placeholder="可选" />
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="resetButton" class="ghost-button" type="button">清空</button>
|
||||
<button class="primary-button" type="submit">保存导航</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="admin-section" data-panel="listPanel">
|
||||
<div class="admin-list-card">
|
||||
<div class="panel-heading list-heading">
|
||||
<div>
|
||||
<p class="panel-kicker">ENTRIES</p>
|
||||
<h2>导航列表</h2>
|
||||
</div>
|
||||
<button id="newFromListButton" class="primary-button compact-button" type="button">新增导航</button>
|
||||
</div>
|
||||
<div class="admin-list-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>分组</th>
|
||||
<th>地址</th>
|
||||
<th>更新</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="serviceRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js?v=6" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
314
public/admin.js
Normal file
314
public/admin.js
Normal 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);
|
||||
});
|
||||
}
|
||||
118
public/app.js
Normal file
118
public/app.js
Normal file
@@ -0,0 +1,118 @@
|
||||
const servicesEl = document.querySelector("#services");
|
||||
const statusBar = document.querySelector("#statusBar");
|
||||
const searchInput = document.querySelector("#searchInput");
|
||||
const refreshButton = document.querySelector("#refreshButton");
|
||||
const groupTemplate = document.querySelector("#groupTemplate");
|
||||
const cardTemplate = document.querySelector("#cardTemplate");
|
||||
const appBrand = document.querySelector("#appBrand");
|
||||
const appTitle = document.querySelector("#appTitle");
|
||||
const appSubtitle = document.querySelector("#appSubtitle");
|
||||
|
||||
let services = [];
|
||||
let settings = {};
|
||||
|
||||
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 setStatus(message, isError = false) {
|
||||
statusBar.textContent = message;
|
||||
statusBar.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
function applySettings(nextSettings = {}) {
|
||||
settings = nextSettings;
|
||||
document.title = settings.browserTitle || "家中服务导航";
|
||||
appBrand.textContent = settings.brand || "STUN NAV";
|
||||
appTitle.textContent = settings.title || "家中服务";
|
||||
appSubtitle.textContent = settings.subtitle || "";
|
||||
appSubtitle.hidden = !appSubtitle.textContent.trim();
|
||||
}
|
||||
|
||||
function serviceMatches(service, keyword) {
|
||||
if (!keyword) return true;
|
||||
const text = `${service.name} ${service.group} ${service.note} ${service.url}`.toLowerCase();
|
||||
return text.includes(keyword.toLowerCase());
|
||||
}
|
||||
|
||||
function groupServices(items) {
|
||||
return items.reduce((groups, service) => {
|
||||
const group = service.group || "默认";
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group).push(service);
|
||||
return groups;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
async function copyUrl(url, button) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
const oldTitle = button.title;
|
||||
button.title = "已复制";
|
||||
setTimeout(() => {
|
||||
button.title = oldTitle;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const keyword = searchInput.value.trim();
|
||||
const filtered = services.filter((service) => serviceMatches(service, keyword));
|
||||
servicesEl.replaceChildren();
|
||||
|
||||
if (!filtered.length) {
|
||||
setStatus(services.length ? "没有匹配的服务" : "还没有收到 Lucky 推送的地址");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(`${filtered.length} 个服务可用,最近刷新 ${formatTime(new Date().toISOString())}`);
|
||||
|
||||
for (const [group, items] of groupServices(filtered)) {
|
||||
const groupNode = groupTemplate.content.firstElementChild.cloneNode(true);
|
||||
groupNode.querySelector("h2").textContent = group;
|
||||
const grid = groupNode.querySelector(".grid");
|
||||
|
||||
for (const service of items) {
|
||||
const card = cardTemplate.content.firstElementChild.cloneNode(true);
|
||||
card.querySelector(".service-mark").dataset.initial = (service.name || service.id || "?").trim().slice(0, 1).toUpperCase();
|
||||
card.querySelector("h3").textContent = service.name;
|
||||
card.querySelector(".note").textContent = service.note || "";
|
||||
card.querySelector(".meta").textContent = `${service.url} · ${formatTime(service.updatedAt)}`;
|
||||
|
||||
const openButton = card.querySelector(".open-button");
|
||||
openButton.href = service.url;
|
||||
|
||||
const copyButton = card.querySelector(".copy-button");
|
||||
copyButton.addEventListener("click", () => copyUrl(service.url, copyButton));
|
||||
grid.append(card);
|
||||
}
|
||||
|
||||
servicesEl.append(groupNode);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
try {
|
||||
setStatus("正在读取最新地址...");
|
||||
const response = await fetch("/api/services", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`读取失败:${response.status}`);
|
||||
const data = await response.json();
|
||||
services = Array.isArray(data.services) ? data.services : [];
|
||||
applySettings(data.settings || {});
|
||||
render();
|
||||
} catch (error) {
|
||||
setStatus(error.message || "读取失败", true);
|
||||
}
|
||||
}
|
||||
|
||||
searchInput.addEventListener("input", render);
|
||||
refreshButton.addEventListener("click", loadServices);
|
||||
|
||||
loadServices();
|
||||
setInterval(loadServices, 30000);
|
||||
75
public/index.html
Normal file
75
public/index.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>家中服务导航</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=3" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar hero">
|
||||
<div class="hero-copy">
|
||||
<p id="appBrand" class="eyebrow">STUN NAV</p>
|
||||
<h1 id="appTitle">家中服务</h1>
|
||||
<p id="appSubtitle" class="subtitle">聚合 Lucky STUN 最新入口,一处打开所有家中应用</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="text-link" href="/admin">管理</a>
|
||||
<label class="search">
|
||||
<span class="search-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
||||
<path d="m21 21-4.35-4.35m2.35-5.65a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<input id="searchInput" type="search" placeholder="搜索服务" autocomplete="off" />
|
||||
</label>
|
||||
<button id="refreshButton" class="icon-button" type="button" title="刷新">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 0 1-15.18 6.57M3 12A9 9 0 0 1 18.18 5.43M21 5v6h-6M3 19v-6h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="statusBar" class="status" aria-live="polite">正在读取最新地址...</section>
|
||||
<section id="services" class="services"></section>
|
||||
</main>
|
||||
|
||||
<template id="groupTemplate">
|
||||
<section class="group">
|
||||
<h2></h2>
|
||||
<div class="grid"></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template id="cardTemplate">
|
||||
<article class="service-card">
|
||||
<div class="card-main">
|
||||
<div class="service-mark" aria-hidden="true"></div>
|
||||
<div class="service-text">
|
||||
<h3></h3>
|
||||
<p class="note"></p>
|
||||
<p class="meta"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="icon-button copy-button" type="button" title="复制地址">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<a class="open-button" target="_blank" rel="noreferrer">
|
||||
<span>打开</span>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path d="M7 17 17 7M9 7h8v8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="/app.js?v=3" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
652
public/styles.css
Normal file
652
public/styles.css
Normal file
@@ -0,0 +1,652 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f6f2;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f9faf7;
|
||||
--ink: #17211f;
|
||||
--muted: #66736f;
|
||||
--line: #dde4df;
|
||||
--accent: #117c6f;
|
||||
--accent-strong: #095f55;
|
||||
--indigo: #405f9f;
|
||||
--amber: #c47a2c;
|
||||
--danger: #b23b4a;
|
||||
--shadow-sm: 0 8px 22px rgba(23, 33, 31, 0.06);
|
||||
--shadow-md: 0 20px 48px rgba(23, 33, 31, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(17, 124, 111, 0.1), transparent 320px),
|
||||
linear-gradient(120deg, rgba(196, 122, 44, 0.09), transparent 360px),
|
||||
var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1160px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 30px 0 56px;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
width: min(1220px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 22px;
|
||||
padding: 26px;
|
||||
border: 1px solid rgba(221, 228, 223, 0.9);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.86), rgba(249, 250, 247, 0.72)),
|
||||
var(--surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.compact-hero {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 5vw, 58px);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 650px;
|
||||
margin: 12px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: min(340px, 38vw);
|
||||
min-height: 44px;
|
||||
gap: 10px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
display: inline-flex;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.text-link,
|
||||
.icon-button,
|
||||
.ghost-button,
|
||||
.small-button,
|
||||
.primary-button,
|
||||
.open-button {
|
||||
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
color: var(--accent);
|
||||
font-weight: 780;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.text-link:hover {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.ghost-button:hover,
|
||||
.small-button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(17, 124, 111, 0.45);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
margin-bottom: 22px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(221, 228, 223, 0.9);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
border-color: rgba(178, 59, 74, 0.35);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.services {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.group h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 12px;
|
||||
color: #24302d;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.group h2::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
display: grid;
|
||||
min-height: 176px;
|
||||
align-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(221, 228, 223, 0.95);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.service-card:hover {
|
||||
border-color: rgba(17, 124, 111, 0.36);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.service-mark {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.service-mark::before {
|
||||
content: attr(data-initial);
|
||||
}
|
||||
|
||||
.service-card:nth-child(3n + 2) .service-mark {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
.service-card:nth-child(3n) .service-mark {
|
||||
background: var(--indigo);
|
||||
}
|
||||
|
||||
.service-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.service-text h3 {
|
||||
margin: 0 0 8px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 19px;
|
||||
line-height: 1.22;
|
||||
}
|
||||
|
||||
.note,
|
||||
.meta {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: #43514d;
|
||||
}
|
||||
|
||||
.note:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.open-button,
|
||||
.primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 780;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.quick-action.active {
|
||||
outline: 2px solid rgba(17, 124, 111, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.open-button:hover,
|
||||
.primary-button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--accent-strong);
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.admin-console {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.admin-sidebar,
|
||||
.admin-list-card,
|
||||
.admin-panel {
|
||||
border: 1px solid rgba(221, 228, 223, 0.95);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 18px;
|
||||
align-self: start;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.token-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 44px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-weight: 780;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
border-color: rgba(17, 124, 111, 0.28);
|
||||
background: rgba(17, 124, 111, 0.1);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.admin-status {
|
||||
margin: 16px 0 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-workspace {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-panel.admin-section.active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.admin-section:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 42px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
margin: 0 0 5px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.settings-form,
|
||||
.service-form {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-soft);
|
||||
color: var(--ink);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: rgba(17, 124, 111, 0.6);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
.small-button {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.small-button {
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
border-color: rgba(178, 59, 74, 0.32);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.danger-button:hover {
|
||||
border-color: rgba(178, 59, 74, 0.58);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-list-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-list-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.list-heading {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
min-width: 880px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.admin-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
max-width: 380px;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell,
|
||||
.admin-shell {
|
||||
width: min(100vw - 24px, 1220px);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-console {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.form-actions .ghost-button,
|
||||
.form-actions .primary-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user