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:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
Reference in New Issue
Block a user