feat: 隐藏后台面板 + /api/stats 运行统计

- 连点页脚提示 6 次唤出管理面板:本机偏好/播放状态 + 服务器统计
- server.py 新增 GET /api/stats:运行时长、访客 IP(X-Forwarded-For
  末位取真实 IP 防伪造)、TTS 请求与缓存命中率、缓存体积、经典数
- 计数为进程内存统计,重启清零

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-10-05 13:19:59 +08:00
co-authored by Devin
parent 42d5606ab5
commit 490f032d60
5 changed files with 181 additions and 5 deletions
+86
View File
@@ -951,3 +951,89 @@ const hashId = location.hash.replace(/^#/, "");
: (SUTRAS.some((s) => s.id === prefs.sutra) ? prefs.sutra : SUTRAS[0]?.id); : (SUTRAS.some((s) => s.id === prefs.sutra) ? prefs.sutra : SUTRAS[0]?.id);
if (initSutraId) selectSutra(initSutraId); if (initSutraId) selectSutra(initSutraId);
})(); })();
// ===== 隐藏后台:2 秒内连点页脚提示 6 次打开管理面板(来源链接点击不计数) =====
const footHint = document.getElementById("foot-hint");
let hintClicks = 0;
let hintTimer = null;
footHint.addEventListener("click", (e) => {
if (e.target.closest("a")) return;
hintClicks += 1;
clearTimeout(hintTimer);
hintTimer = setTimeout(() => { hintClicks = 0; }, 2000);
if (hintClicks >= 6) {
hintClicks = 0;
openAdmin();
}
});
/** 字节数转可读文本。 */
function fmtBytes(n) {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
return `${n} B`;
}
/** 秒数转可读运行时长(如 2天3时 / 4时12分 / 35秒)。 */
function fmtUptime(sec) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
if (d) return `${d}天${h}时`;
if (h) return `${h}时${m}分`;
if (m) return `${m}分${sec % 60}秒`;
return `${sec}秒`;
}
/** 打开管理面板:本机偏好/播放状态 + 服务器 /api/stats 统计,可点"刷新"重拉。 */
function openAdmin() {
const mask = document.createElement("div");
mask.className = "admin-mask";
const card = document.createElement("div");
card.className = "admin-card";
card.setAttribute("role", "dialog");
card.setAttribute("aria-label", "管理面板");
const voiceName = voiceSelect.options[voiceSelect.selectedIndex]?.textContent || "-";
const bgmName = bgmSelect.options[bgmSelect.selectedIndex]?.textContent || "关";
card.innerHTML = `
<div class="admin-title">后台</div>
<div class="admin-sec">本机</div>
<div class="admin-rows">
<div class="admin-row"><span>经典</span><b>${sutra ? sutra.name : "-"}</b></div>
<div class="admin-row"><span>音色</span><b>${voiceName}</b></div>
<div class="admin-row"><span>语速</span><b>${rateSelect.value}×</b></div>
<div class="admin-row"><span>配乐</span><b>${bgmName}</b></div>
<div class="admin-row"><span>循环 / 夜间</span><b>${loopMode ? "开" : "关"} / ${document.body.classList.contains("night") ? "开" : "关"}</b></div>
<div class="admin-row"><span>播放</span><b>${playing ? `第 ${currentSeg + 1}/${segments.length} 段` : "未在播"}</b></div>
<div class="admin-row"><span>浏览器</span><b>${/Mobile/.test(navigator.userAgent) ? "移动端" : "桌面端"}${navigator.serviceWorker?.controller ? " · PWA" : ""}</b></div>
</div>
<div class="admin-sec">服务器</div>
<div class="admin-rows" id="admin-server"><div class="admin-row"><span>载入中…</span></div></div>
<div class="admin-actions">
<button type="button" class="admin-btn" id="admin-refresh">刷新</button>
<button type="button" class="admin-btn" id="admin-close">关闭</button>
</div>`;
mask.appendChild(card);
document.body.appendChild(mask);
const close = () => mask.remove();
mask.addEventListener("click", (e) => { if (e.target === mask) close(); });
card.querySelector("#admin-close").addEventListener("click", close);
const loadStats = async () => {
const box = card.querySelector("#admin-server");
try {
const s = await (await fetch("/api/stats")).json();
const hitRate = s.tts_requests ? `${Math.round((s.cache_hits / s.tts_requests) * 100)}%` : "-";
box.innerHTML = `
<div class="admin-row"><span>运行时长</span><b>${fmtUptime(s.uptime_sec)}</b></div>
<div class="admin-row"><span>访客 IP</span><b>${s.visitors}</b></div>
<div class="admin-row"><span>TTS 请求</span><b>${s.tts_requests}(命中 ${s.cache_hits} · ${hitRate})</b></div>
<div class="admin-row"><span>语音缓存</span><b>${s.cache_files} 个 · ${fmtBytes(s.cache_bytes)}</b></div>
<div class="admin-row"><span>经典数 / 模型</span><b>${s.sutra_count} 部 · ${s.model}</b></div>`;
} catch {
box.innerHTML = '<div class="admin-row"><span>服务器统计不可用</span></div>';
}
};
loadStats();
card.querySelector("#admin-refresh").addEventListener("click", loadStats);
}
+3 -3
View File
@@ -13,7 +13,7 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="经典诵读"> <meta name="apple-mobile-web-app-title" content="经典诵读">
<link rel="stylesheet" href="style.css?v=10"> <link rel="stylesheet" href="style.css?v=11">
</head> </head>
<body> <body>
@@ -112,10 +112,10 @@
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<p>点击段落可从该段起诵读 · ← → 键翻页 · 语音由千问 Qwen3-TTS 合成 · 经文来源:<a id="sutra-source" href="https://www.dpm.org.cn/lemmas/243222.html" target="_blank" rel="noopener">故宫博物院</a></p> <p id="foot-hint">点击段落可从该段起诵读 · ← → 键翻页 · 语音由千问 Qwen3-TTS 合成 · 经文来源:<a id="sutra-source" href="https://www.dpm.org.cn/lemmas/243222.html" target="_blank" rel="noopener">故宫博物院</a></p>
</footer> </footer>
<audio id="audio" preload="auto"></audio> <audio id="audio" preload="auto"></audio>
<script src="app.js?v=12"></script> <script src="app.js?v=13"></script>
</body> </body>
</html> </html>
+30 -1
View File
@@ -4,6 +4,7 @@
import hashlib import hashlib
import json import json
import os import os
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
@@ -39,6 +40,10 @@ PORT = int(os.environ.get("PORT", "8000"))
GENERATION_URL = f"{DASHSCOPE_BASE}/services/aigc/multimodal-generation/generation" GENERATION_URL = f"{DASHSCOPE_BASE}/services/aigc/multimodal-generation/generation"
# 运行期统计:进程内存内计数,重启清零
STATS = {"start": time.time(), "tts_requests": 0, "cache_hits": 0}
VISITORS = set() # 见过的客户端 IP(本次进程生命周期内)
def http_post_json(url: str, payload: dict, headers: dict, timeout: int = 60) -> dict: def http_post_json(url: str, payload: dict, headers: dict, timeout: int = 60) -> dict:
"""发送 JSON POST 请求并返回解析后的响应。""" """发送 JSON POST 请求并返回解析后的响应。"""
@@ -72,8 +77,29 @@ class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs) super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def _track_visitor(self) -> None:
"""记录客户端 IP 到访客集合;nginx 反代后真实来源在 X-Forwarded-For / X-Real-IP。"""
# nginx 把真实 IP 追加在 X-Forwarded-For 末尾,取最后一个元素防伪造
xff = (self.headers.get("X-Forwarded-For") or "").split(",")[-1].strip()
ip = xff or (self.headers.get("X-Real-IP") or "").strip() or self.client_address[0]
VISITORS.add(ip)
def do_GET(self) -> None: def do_GET(self) -> None:
"""处理 GET 请求:/api/sutras 返回经典目录清单,其余按静态文件处理。""" """处理 GET 请求:/api/* 接口,其余按静态文件处理。"""
self._track_visitor()
if self.path == "/api/stats":
cache_files = list(CACHE_DIR.glob("*.wav")) if CACHE_DIR.is_dir() else []
self._send_json({
"uptime_sec": int(time.time() - STATS["start"]),
"tts_requests": STATS["tts_requests"],
"cache_hits": STATS["cache_hits"],
"cache_files": len(cache_files),
"cache_bytes": sum(f.stat().st_size for f in cache_files),
"sutra_count": len(list(SUTRA_DIR.glob("*.json"))) if SUTRA_DIR.is_dir() else 0,
"visitors": len(VISITORS),
"model": TTS_MODEL,
})
return
if self.path == "/api/sutras": if self.path == "/api/sutras":
try: try:
items = [] items = []
@@ -95,6 +121,7 @@ class Handler(SimpleHTTPRequestHandler):
def do_POST(self) -> None: def do_POST(self) -> None:
"""处理 POST 请求,目前仅支持 /api/tts。""" """处理 POST 请求,目前仅支持 /api/tts。"""
self._track_visitor()
if self.path != "/api/tts": if self.path != "/api/tts":
self._send_json({"error": "not found"}, status=404) self._send_json({"error": "not found"}, status=404)
return return
@@ -119,8 +146,10 @@ class Handler(SimpleHTTPRequestHandler):
cache_key = hashlib.sha256(f"{TTS_MODEL}|{voice}|{text}".encode("utf-8")).hexdigest()[:24] cache_key = hashlib.sha256(f"{TTS_MODEL}|{voice}|{text}".encode("utf-8")).hexdigest()[:24]
cache_file = CACHE_DIR / f"{cache_key}.wav" cache_file = CACHE_DIR / f"{cache_key}.wav"
STATS["tts_requests"] += 1
try: try:
if cache_file.exists(): if cache_file.exists():
STATS["cache_hits"] += 1
audio = cache_file.read_bytes() audio = cache_file.read_bytes()
else: else:
audio = synthesize(text, voice) audio = synthesize(text, voice)
+61
View File
@@ -714,3 +714,64 @@ body.night .seg.playing { background: rgba(201, 106, 92, 0.16); color: #f0c9c2;
flex: none; flex: none;
} }
.sheet-cancel:active { background: rgba(176, 138, 79, 0.12); } .sheet-cancel:active { background: rgba(176, 138, 79, 0.12); }
/* ===== 隐藏管理面板:连点页脚 6 次唤出 ===== */
.admin-mask {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(30, 22, 14, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.admin-card {
width: min(420px, 100%);
max-height: 80vh;
overflow-y: auto;
background: var(--paper);
border: 1px solid var(--gold);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(30, 22, 14, 0.35);
padding: 16px 18px;
color: var(--ink);
}
.admin-title {
text-align: center;
font-size: 1rem;
letter-spacing: 0.4em;
color: var(--cinnabar);
margin-bottom: 10px;
}
.admin-sec {
font-size: 0.72rem;
color: var(--gold);
letter-spacing: 0.2em;
border-bottom: 1px solid var(--hairline);
padding: 8px 0 4px;
}
.admin-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.82rem;
padding: 5px 0;
}
.admin-row span { color: var(--ink-soft); }
.admin-row b { font-weight: 500; text-align: right; }
.admin-actions { display: flex; gap: 10px; margin-top: 12px; }
.admin-btn {
flex: 1;
padding: 8px;
font-family: inherit;
font-size: 0.82rem;
letter-spacing: 0.15em;
color: var(--ink-soft);
background: transparent;
border: 1px solid var(--hairline);
border-radius: 6px;
cursor: pointer;
}
.admin-btn:hover { border-color: var(--gold); color: var(--ink); }
body.night .admin-card { background: #241d15; }
+1 -1
View File
@@ -4,7 +4,7 @@
* - /api/tts 不拦截,直接透传网络 * - /api/tts 不拦截,直接透传网络
*/ */
const CACHE = "fj-shell-v1"; const CACHE = "fj-shell-v1";
const SHELL = ["/", "/index.html", "/style.css?v=10", "/app.js?v=12", "/icon.svg", "/icon-180.png", "/icon-192.png", "/icon-512.png", "/manifest.json"]; const SHELL = ["/", "/index.html", "/style.css?v=11", "/app.js?v=13", "/icon.svg", "/icon-180.png", "/icon-192.png", "/icon-512.png", "/manifest.json"];
self.addEventListener("install", (e) => { self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));