Initial stun navigation app
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
data/*.json
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
63
EDGEONE.md
Normal file
63
EDGEONE.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# EdgeOne Pages 部署说明
|
||||||
|
|
||||||
|
这个项目已经包含 EdgeOne Pages 版本:
|
||||||
|
|
||||||
|
- 静态页面:`public/`
|
||||||
|
- Edge Functions:`edge-functions/api/[[default]].js`
|
||||||
|
- EdgeOne 配置:`edgeone.json`
|
||||||
|
|
||||||
|
## 需要在 EdgeOne 配置的内容
|
||||||
|
|
||||||
|
1. 创建一个 EdgeOne Pages / Makers 项目,并导入这个仓库。
|
||||||
|
2. 创建并绑定 KV Namespace。
|
||||||
|
3. 绑定 KV 时,变量名填写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
STUN_NAV_KV
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 配置环境变量:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WEBHOOK_TOKEN=换成你自己的随机密钥
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 部署后访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://你的域名/
|
||||||
|
https://你的域名/admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lucky Webhook 示例
|
||||||
|
|
||||||
|
简单 URL 参数方式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://你的域名/api/webhook/alist?token=你的随机密钥&name=AList&group=文件&url=http://{STUN_你的STUN规则名_ADDR}
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 方式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
URL: https://你的域名/api/webhook/alist?token=你的随机密钥
|
||||||
|
Method: POST
|
||||||
|
Content-Type: application/json
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"name": "AList",
|
||||||
|
"group": "文件",
|
||||||
|
"url": "http://{STUN_你的STUN规则名_ADDR}",
|
||||||
|
"note": "家中文件服务"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
EdgeOne 版本不再使用 `data/links.json`,所有数据会存到 KV 的 `stun_nav_store` 这个 key 里。
|
||||||
|
|
||||||
|
本地 Node 版本仍然保留,可以继续用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBHOOK_TOKEN=你的随机密钥 PORT=8080 npm start
|
||||||
|
```
|
||||||
103
README.md
Normal file
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# STUN 导航页
|
||||||
|
|
||||||
|
一个给 Lucky STUN 使用的个人导航页。Lucky 的 STUN 地址变化后,通过 webhook 把最新地址推到这里;浏览器打开导航页时,按钮会跳转到最新地址。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBHOOK_TOKEN=换成一串随机密钥 PORT=8080 npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
然后访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://服务器IP:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
管理页:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://服务器IP:8080/admin
|
||||||
|
```
|
||||||
|
|
||||||
|
打开管理页后填写 `WEBHOOK_TOKEN`,即可手动新增、修改、删除导航项,也可以修改浏览器标题、顶部标识、首页标题和副标题。密钥只保存在当前浏览器本地。
|
||||||
|
|
||||||
|
## 部署到 EdgeOne Pages
|
||||||
|
|
||||||
|
项目已经包含 EdgeOne Pages 改造版本,见 `EDGEONE.md`。
|
||||||
|
|
||||||
|
核心变化:
|
||||||
|
|
||||||
|
- 静态页面从 `public/` 部署
|
||||||
|
- `/api/*` 由 `edge-functions/api/[[default]].js` 提供
|
||||||
|
- 数据保存到 EdgeOne KV,绑定变量名需要设置为 `STUN_NAV_KV`
|
||||||
|
- 管理密钥使用环境变量 `WEBHOOK_TOKEN`
|
||||||
|
|
||||||
|
## Lucky Webhook 配置
|
||||||
|
|
||||||
|
推荐让 Lucky 发送 JSON:
|
||||||
|
|
||||||
|
```text
|
||||||
|
URL: http://你的导航页地址:8080/api/webhook/alist?token=换成一串随机密钥
|
||||||
|
Method: POST
|
||||||
|
Content-Type: application/json
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"name": "AList",
|
||||||
|
"group": "文件",
|
||||||
|
"url": "http://{STUN_你的STUN规则名_ADDR}",
|
||||||
|
"note": "家中文件服务"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以不用 JSON,直接用 URL 参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://你的导航页地址:8080/api/webhook/alist?token=换成一串随机密钥&name=AList&group=文件&url=http://{STUN_你的STUN规则名_ADDR}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lucky 的全局变量需要把 `规则名` 替换成 STUN 穿透规则列表里的真实规则名,不是导航页服务名,也不是环境变量。例如你的 STUN 规则名叫 `alist-web`,就写 `{STUN_alist-web_ADDR}`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
{STUN_规则名_ADDR}
|
||||||
|
{STUN_规则名_IP}
|
||||||
|
{STUN_规则名_PORT}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webhook 支持的字段
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | 服务唯一标识;也可以写在 `/api/webhook/:id` 里 |
|
||||||
|
| `name` | 页面显示名称 |
|
||||||
|
| `group` | 页面分组 |
|
||||||
|
| `url` | 完整跳转地址,例如 `http://1.2.3.4:12345` |
|
||||||
|
| `addr` | 地址加端口,例如 `1.2.3.4:12345` |
|
||||||
|
| `host` / `ip` + `port` | 分开发送主机和端口 |
|
||||||
|
| `protocol` / `scheme` | `http` 或 `https`,默认 `http` |
|
||||||
|
| `path` | 跳转路径,例如 `/admin` |
|
||||||
|
| `note` | 备注 |
|
||||||
|
|
||||||
|
收到的数据会保存在 `data/links.json`。
|
||||||
|
|
||||||
|
## 删除导航
|
||||||
|
|
||||||
|
删除某个导航项时,用它的 `id` 调用删除接口。`id` 默认来自 `/api/webhook/:id` 里的那一段,例如 `/api/webhook/alist` 创建的导航,删除时就是:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE "http://你的导航页地址:8080/api/services/alist?token=换成一串随机密钥"
|
||||||
|
```
|
||||||
|
|
||||||
|
删除刚才测试的百度示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE "http://你的导航页地址:8080/api/services/baidu?token=换成一串随机密钥"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果不想调用接口,也可以停掉服务后直接编辑 `data/links.json`,删除对应对象,再重新启动服务。
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
- 一定要设置 `WEBHOOK_TOKEN`。
|
||||||
|
- 不建议直接把导航页暴露到公网裸奔,最好放到 HTTPS 反代后面。
|
||||||
|
- 如果导航页本身也通过 STUN 暴露,至少给入口加访问控制或复杂路径。
|
||||||
243
edge-functions/api/[[default]].js
Normal file
243
edge-functions/api/[[default]].js
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
const STORE_KEY = "stun_nav_store";
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
browserTitle: "家中服务导航",
|
||||||
|
brand: "STUN NAV",
|
||||||
|
title: "家中服务",
|
||||||
|
subtitle: "聚合 Lucky STUN 最新入口,一处打开所有家中应用"
|
||||||
|
};
|
||||||
|
|
||||||
|
function json(payload, status = 200) {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"cache-control": "no-store"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstValue(...values) {
|
||||||
|
return values.find((value) => value !== undefined && value !== null && String(value).trim() !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimText(value, fallback, maxLength = 80) {
|
||||||
|
const text = String(firstValue(value, fallback)).trim();
|
||||||
|
return text.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSettings(input = {}) {
|
||||||
|
return {
|
||||||
|
browserTitle: trimText(input.browserTitle, DEFAULT_SETTINGS.browserTitle, 80),
|
||||||
|
brand: trimText(input.brand, DEFAULT_SETTINGS.brand, 40),
|
||||||
|
title: trimText(input.title, DEFAULT_SETTINGS.title, 80),
|
||||||
|
subtitle: trimText(input.subtitle, DEFAULT_SETTINGS.subtitle, 140)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\u4e00-\u9fa5_-]+/gi, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
if (/^https?:\/\//i.test(raw)) return raw;
|
||||||
|
return `http://${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(input) {
|
||||||
|
const directUrl = firstValue(input.url, input.href, input.link, input.target);
|
||||||
|
if (directUrl) return normalizeUrl(String(directUrl));
|
||||||
|
|
||||||
|
const addr = firstValue(input.addr, input.address, input.external, input.stun_addr);
|
||||||
|
if (addr) return normalizeUrl(`${input.scheme || input.protocol || "http"}://${addr}`);
|
||||||
|
|
||||||
|
const host = firstValue(input.host, input.ip, input.stun_ip);
|
||||||
|
const port = firstValue(input.port, input.stun_port);
|
||||||
|
if (!host || !port) return "";
|
||||||
|
|
||||||
|
const path = firstValue(input.path, input.pathname) || "";
|
||||||
|
return normalizeUrl(`${input.scheme || input.protocol || "http"}://${host}:${port}${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKv(env = {}) {
|
||||||
|
return env.STUN_NAV_KV || env.stun_nav_kv || globalThis.STUN_NAV_KV || globalThis.stun_nav_kv || globalThis.my_kv;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebhookToken(env = {}) {
|
||||||
|
return env.WEBHOOK_TOKEN || globalThis.WEBHOOK_TOKEN || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestToken(request, url) {
|
||||||
|
const auth = request.headers.get("authorization") || "";
|
||||||
|
if (auth.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim();
|
||||||
|
return request.headers.get("x-webhook-token") || url.searchParams.get("token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqual(a, b) {
|
||||||
|
const left = String(a);
|
||||||
|
const right = String(b);
|
||||||
|
let diff = left.length ^ right.length;
|
||||||
|
const length = Math.max(left.length, right.length);
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
diff |= left.charCodeAt(index % left.length || 0) ^ right.charCodeAt(index % right.length || 0);
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorized(request, url, env) {
|
||||||
|
const expected = getWebhookToken(env);
|
||||||
|
if (!expected) return true;
|
||||||
|
return timingSafeEqual(getRequestToken(request, url), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(request) {
|
||||||
|
const raw = (await request.text()).trim();
|
||||||
|
if (!raw) return {};
|
||||||
|
|
||||||
|
const contentType = request.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json")) return JSON.parse(raw);
|
||||||
|
if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||||
|
return Object.fromEntries(new URLSearchParams(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { url: raw };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStore(kv) {
|
||||||
|
if (!kv) {
|
||||||
|
throw new Error("KV namespace is not bound. Bind it with variable name STUN_NAV_KV.");
|
||||||
|
}
|
||||||
|
const data = (await kv.get(STORE_KEY, { type: "json" })) || {};
|
||||||
|
return {
|
||||||
|
settings: sanitizeSettings(data.settings || {}),
|
||||||
|
services: Array.isArray(data.services) ? data.services : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStore(kv, data) {
|
||||||
|
await kv.put(STORE_KEY, JSON.stringify({
|
||||||
|
settings: sanitizeSettings(data.settings || {}),
|
||||||
|
services: Array.isArray(data.services) ? data.services : []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceFromWebhook(idFromPath, query, body) {
|
||||||
|
const merged = { ...query, ...body };
|
||||||
|
const name = firstValue(merged.name, merged.service, merged.title, idFromPath);
|
||||||
|
const id = slugify(firstValue(merged.id, merged.key, idFromPath, name));
|
||||||
|
const url = buildUrl(merged);
|
||||||
|
const protocol = firstValue(merged.protocol, merged.scheme, "http");
|
||||||
|
|
||||||
|
if (!id) return { error: "missing service id or name" };
|
||||||
|
if (!url) return { error: "missing service url, addr, or host+port" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: {
|
||||||
|
id,
|
||||||
|
name: String(name || id),
|
||||||
|
url,
|
||||||
|
group: String(firstValue(merged.group, merged.category, "默认")),
|
||||||
|
protocol: String(protocol).replace(/:$/, ""),
|
||||||
|
note: String(firstValue(merged.note, merged.description) || ""),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listServices(kv) {
|
||||||
|
const data = await readStore(kv);
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
settings: data.settings,
|
||||||
|
services: data.services.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
url: item.url,
|
||||||
|
group: item.group || "默认",
|
||||||
|
note: item.note || "",
|
||||||
|
updatedAt: item.updatedAt || item.createdAt || ""
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateService(request, url, kv, idFromPath) {
|
||||||
|
const body = request.method === "GET" ? {} : await readBody(request);
|
||||||
|
const query = Object.fromEntries(url.searchParams);
|
||||||
|
const result = serviceFromWebhook(idFromPath, query, body);
|
||||||
|
if (result.error) return json({ ok: false, error: result.error }, 400);
|
||||||
|
|
||||||
|
const data = await readStore(kv);
|
||||||
|
const index = data.services.findIndex((item) => item.id === result.service.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
data.services[index] = { ...data.services[index], ...result.service };
|
||||||
|
} else {
|
||||||
|
data.services.push({ createdAt: result.service.updatedAt, ...result.service });
|
||||||
|
}
|
||||||
|
|
||||||
|
data.services.sort((a, b) => `${a.group}${a.name}`.localeCompare(`${b.group}${b.name}`, "zh-Hans-CN"));
|
||||||
|
await writeStore(kv, data);
|
||||||
|
return json({ ok: true, service: result.service });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSettings(request, kv) {
|
||||||
|
const body = await readBody(request);
|
||||||
|
const data = await readStore(kv);
|
||||||
|
data.settings = sanitizeSettings({ ...data.settings, ...body });
|
||||||
|
await writeStore(kv, data);
|
||||||
|
return json({ ok: true, settings: data.settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteService(kv, idFromPath) {
|
||||||
|
const id = slugify(idFromPath);
|
||||||
|
if (!id) return json({ ok: false, error: "missing service id" }, 400);
|
||||||
|
|
||||||
|
const data = await readStore(kv);
|
||||||
|
const before = data.services.length;
|
||||||
|
data.services = data.services.filter((item) => item.id !== id);
|
||||||
|
await writeStore(kv, data);
|
||||||
|
return json({ ok: true, deleted: before !== data.services.length, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onRequest({ request, env }) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const kv = getKv(env);
|
||||||
|
const path = url.pathname.replace(/^\/api\/?/, "");
|
||||||
|
const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
|
||||||
|
|
||||||
|
if (request.method === "OPTIONS") return json({ ok: true });
|
||||||
|
|
||||||
|
if (parts[0] === "services" && parts.length === 1 && request.method === "GET") {
|
||||||
|
return await listServices(kv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts[0] === "webhook" && ["GET", "POST", "PUT", "PATCH"].includes(request.method)) {
|
||||||
|
if (!isAuthorized(request, url, env)) return json({ ok: false, error: "invalid webhook token" }, 401);
|
||||||
|
return await updateService(request, url, kv, parts[1] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts[0] === "settings" && ["POST", "PUT", "PATCH"].includes(request.method)) {
|
||||||
|
if (!isAuthorized(request, url, env)) return json({ ok: false, error: "invalid webhook token" }, 401);
|
||||||
|
return await updateSettings(request, kv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts[0] === "services" && parts[1] && request.method === "DELETE") {
|
||||||
|
if (!isAuthorized(request, url, env)) return json({ ok: false, error: "invalid webhook token" }, 401);
|
||||||
|
return await deleteService(kv, parts[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: false, error: "not found" }, 404);
|
||||||
|
} catch (error) {
|
||||||
|
return json({ ok: false, error: error.message || "server error" }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
edgeone.json
Normal file
21
edgeone.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"outputDirectory": "./public",
|
||||||
|
"nodeVersion": "22.11.0",
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/admin",
|
||||||
|
"destination": "/admin.html"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/*",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"key": "Cache-Control",
|
||||||
|
"value": "no-cache"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "stun-nav",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs",
|
||||||
|
"dev": "node --watch server.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
340
server.mjs
Normal file
340
server.mjs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFile, writeFile, mkdir, stat, rename } from "node:fs/promises";
|
||||||
|
import { extname, join, normalize } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
const PORT = Number(process.env.PORT || 8080);
|
||||||
|
const WEBHOOK_TOKEN = process.env.WEBHOOK_TOKEN || "";
|
||||||
|
const DATA_DIR = join(__dirname, "data");
|
||||||
|
const DATA_FILE = join(DATA_DIR, "links.json");
|
||||||
|
const PUBLIC_DIR = join(__dirname, "public");
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
browserTitle: "家中服务导航",
|
||||||
|
brand: "STUN NAV",
|
||||||
|
title: "家中服务",
|
||||||
|
subtitle: "聚合 Lucky STUN 最新入口,一处打开所有家中应用"
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentTypes = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml; charset=utf-8"
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureDataFile() {
|
||||||
|
await mkdir(DATA_DIR, { recursive: true });
|
||||||
|
try {
|
||||||
|
await stat(DATA_FILE);
|
||||||
|
} catch {
|
||||||
|
await writeFile(DATA_FILE, JSON.stringify({ settings: DEFAULT_SETTINGS, services: [] }, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStore() {
|
||||||
|
await ensureDataFile();
|
||||||
|
const raw = await readFile(DATA_FILE, "utf8");
|
||||||
|
const data = JSON.parse(raw || "{}");
|
||||||
|
return {
|
||||||
|
settings: sanitizeSettings(data.settings || {}),
|
||||||
|
services: Array.isArray(data.services) ? data.services : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStore(data) {
|
||||||
|
await ensureDataFile();
|
||||||
|
const tempFile = `${DATA_FILE}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
await writeFile(tempFile, JSON.stringify(data, null, 2));
|
||||||
|
await rename(tempFile, DATA_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, status, payload) {
|
||||||
|
res.writeHead(status, {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"cache-control": "no-store"
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendText(res, status, text) {
|
||||||
|
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
|
||||||
|
res.end(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthorized(res) {
|
||||||
|
sendJson(res, 401, { ok: false, error: "invalid webhook token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(req, url) {
|
||||||
|
const auth = req.headers.authorization || "";
|
||||||
|
if (auth.toLowerCase().startsWith("bearer ")) {
|
||||||
|
return auth.slice(7).trim();
|
||||||
|
}
|
||||||
|
return req.headers["x-webhook-token"] || url.searchParams.get("token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebhookAuthorized(req, url) {
|
||||||
|
if (!WEBHOOK_TOKEN) return true;
|
||||||
|
const actual = crypto.createHash("sha256").update(String(getToken(req, url))).digest();
|
||||||
|
const expected = crypto.createHash("sha256").update(WEBHOOK_TOKEN).digest();
|
||||||
|
return crypto.timingSafeEqual(
|
||||||
|
actual,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(req) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
if (Buffer.concat(chunks).length > 1024 * 1024) {
|
||||||
|
throw new Error("request body too large");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||||||
|
if (!raw) return {};
|
||||||
|
|
||||||
|
const contentType = req.headers["content-type"] || "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||||
|
return Object.fromEntries(new URLSearchParams(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { url: raw };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\u4e00-\u9fa5_-]+/gi, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstValue(...values) {
|
||||||
|
return values.find((value) => value !== undefined && value !== null && String(value).trim() !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimText(value, fallback, maxLength = 80) {
|
||||||
|
const text = String(firstValue(value, fallback)).trim();
|
||||||
|
return text.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSettings(input = {}) {
|
||||||
|
return {
|
||||||
|
browserTitle: trimText(input.browserTitle, DEFAULT_SETTINGS.browserTitle, 80),
|
||||||
|
brand: trimText(input.brand, DEFAULT_SETTINGS.brand, 40),
|
||||||
|
title: trimText(input.title, DEFAULT_SETTINGS.title, 80),
|
||||||
|
subtitle: trimText(input.subtitle, DEFAULT_SETTINGS.subtitle, 140)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(input) {
|
||||||
|
const directUrl = firstValue(input.url, input.href, input.link, input.target);
|
||||||
|
if (directUrl) return normalizeUrl(String(directUrl));
|
||||||
|
|
||||||
|
const addr = firstValue(input.addr, input.address, input.external, input.stun_addr);
|
||||||
|
if (addr) {
|
||||||
|
return normalizeUrl(`${input.scheme || input.protocol || "http"}://${addr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = firstValue(input.host, input.ip, input.stun_ip);
|
||||||
|
const port = firstValue(input.port, input.stun_port);
|
||||||
|
if (!host || !port) return "";
|
||||||
|
|
||||||
|
const path = firstValue(input.path, input.pathname) || "";
|
||||||
|
return normalizeUrl(`${input.scheme || input.protocol || "http"}://${host}:${port}${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
if (/^https?:\/\//i.test(raw)) return raw;
|
||||||
|
return `http://${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceFromWebhook(idFromPath, query, body) {
|
||||||
|
const merged = { ...query, ...body };
|
||||||
|
const name = firstValue(merged.name, merged.service, merged.title, idFromPath);
|
||||||
|
const id = slugify(firstValue(merged.id, merged.key, idFromPath, name));
|
||||||
|
const url = buildUrl(merged);
|
||||||
|
const protocol = firstValue(merged.protocol, merged.scheme, "http");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return { error: "missing service id or name" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return { error: "missing service url, addr, or host+port" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: {
|
||||||
|
id,
|
||||||
|
name: String(name || id),
|
||||||
|
url,
|
||||||
|
group: String(firstValue(merged.group, merged.category, "默认")),
|
||||||
|
protocol: String(protocol).replace(/:$/, ""),
|
||||||
|
note: String(firstValue(merged.note, merged.description) || ""),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWebhook(req, res, url, idFromPath) {
|
||||||
|
if (!isWebhookAuthorized(req, url)) {
|
||||||
|
unauthorized(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = {};
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
body = await readBody(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = Object.fromEntries(url.searchParams);
|
||||||
|
const result = serviceFromWebhook(idFromPath, query, body);
|
||||||
|
if (result.error) {
|
||||||
|
sendJson(res, 400, { ok: false, error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readStore();
|
||||||
|
const index = data.services.findIndex((item) => item.id === result.service.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
data.services[index] = { ...data.services[index], ...result.service };
|
||||||
|
} else {
|
||||||
|
data.services.push({ createdAt: result.service.updatedAt, ...result.service });
|
||||||
|
}
|
||||||
|
|
||||||
|
data.services.sort((a, b) => `${a.group}${a.name}`.localeCompare(`${b.group}${b.name}`, "zh-Hans-CN"));
|
||||||
|
await writeStore(data);
|
||||||
|
sendJson(res, 200, { ok: true, service: result.service });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleList(res) {
|
||||||
|
const data = await readStore();
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
settings: data.settings,
|
||||||
|
services: data.services.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
url: item.url,
|
||||||
|
group: item.group || "默认",
|
||||||
|
note: item.note || "",
|
||||||
|
updatedAt: item.updatedAt || item.createdAt || ""
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateSettings(req, res, url) {
|
||||||
|
if (!isWebhookAuthorized(req, url)) {
|
||||||
|
unauthorized(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(req);
|
||||||
|
const data = await readStore();
|
||||||
|
data.settings = sanitizeSettings({ ...data.settings, ...body });
|
||||||
|
await writeStore(data);
|
||||||
|
sendJson(res, 200, { ok: true, settings: data.settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(req, res, url, idFromPath) {
|
||||||
|
if (!isWebhookAuthorized(req, url)) {
|
||||||
|
unauthorized(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = slugify(idFromPath);
|
||||||
|
if (!id) {
|
||||||
|
sendJson(res, 400, { ok: false, error: "missing service id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readStore();
|
||||||
|
const before = data.services.length;
|
||||||
|
data.services = data.services.filter((item) => item.id !== id);
|
||||||
|
await writeStore(data);
|
||||||
|
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
deleted: before !== data.services.length,
|
||||||
|
id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveStatic(req, res, url) {
|
||||||
|
let pathname = decodeURIComponent(url.pathname);
|
||||||
|
if (pathname === "/") pathname = "/index.html";
|
||||||
|
if (pathname === "/admin") pathname = "/admin.html";
|
||||||
|
|
||||||
|
const filePath = normalize(join(PUBLIC_DIR, pathname));
|
||||||
|
if (!filePath.startsWith(PUBLIC_DIR)) {
|
||||||
|
sendText(res, 403, "Forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath);
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": contentTypes[extname(filePath)] || "application/octet-stream",
|
||||||
|
"cache-control": "no-cache"
|
||||||
|
});
|
||||||
|
res.end(content);
|
||||||
|
} catch {
|
||||||
|
sendText(res, 404, "Not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||||
|
const webhookMatch = url.pathname.match(/^\/api\/webhook(?:\/([^/]+))?\/?$/);
|
||||||
|
|
||||||
|
if (webhookMatch && ["GET", "POST", "PUT", "PATCH"].includes(req.method || "")) {
|
||||||
|
await handleWebhook(req, res, url, webhookMatch[1] ? decodeURIComponent(webhookMatch[1]) : "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/services" && req.method === "GET") {
|
||||||
|
await handleList(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/settings" && ["POST", "PUT", "PATCH"].includes(req.method || "")) {
|
||||||
|
await handleUpdateSettings(req, res, url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMatch = url.pathname.match(/^\/api\/services\/([^/]+)\/?$/);
|
||||||
|
if (deleteMatch && req.method === "DELETE") {
|
||||||
|
await handleDelete(req, res, url, decodeURIComponent(deleteMatch[1]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await serveStatic(req, res, url);
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, { ok: false, error: error.message || "server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`STUN navigation is running at http://localhost:${PORT}`);
|
||||||
|
if (!WEBHOOK_TOKEN) {
|
||||||
|
console.log("WEBHOOK_TOKEN is not set. Set it before exposing this service.");
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user