diff --git a/app.js b/app.js index 58cf59b..bb6c702 100644 --- a/app.js +++ b/app.js @@ -951,3 +951,89 @@ const hashId = location.hash.replace(/^#/, ""); : (SUTRAS.some((s) => s.id === prefs.sutra) ? prefs.sutra : SUTRAS[0]?.id); 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 = ` +
后台
+
本机
+
+
经典${sutra ? sutra.name : "-"}
+
音色${voiceName}
+
语速${rateSelect.value}×
+
配乐${bgmName}
+
循环 / 夜间${loopMode ? "开" : "关"} / ${document.body.classList.contains("night") ? "开" : "关"}
+
播放${playing ? `第 ${currentSeg + 1}/${segments.length} 段` : "未在播"}
+
浏览器${/Mobile/.test(navigator.userAgent) ? "移动端" : "桌面端"}${navigator.serviceWorker?.controller ? " · PWA" : ""}
+
+
服务器
+
载入中…
+
+ + +
`; + 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 = ` +
运行时长${fmtUptime(s.uptime_sec)}
+
访客 IP${s.visitors}
+
TTS 请求${s.tts_requests}(命中 ${s.cache_hits} · ${hitRate})
+
语音缓存${s.cache_files} 个 · ${fmtBytes(s.cache_bytes)}
+
经典数 / 模型${s.sutra_count} 部 · ${s.model}
`; + } catch { + box.innerHTML = '
服务器统计不可用
'; + } + }; + loadStats(); + card.querySelector("#admin-refresh").addEventListener("click", loadStats); +} diff --git a/index.html b/index.html index 6d3dfde..b4e38e3 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ - + @@ -112,10 +112,10 @@ - + diff --git a/server.py b/server.py index 4761e17..3f310e4 100644 --- a/server.py +++ b/server.py @@ -4,6 +4,7 @@ import hashlib import json import os +import time import urllib.error import urllib.request 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" +# 运行期统计:进程内存内计数,重启清零 +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: """发送 JSON POST 请求并返回解析后的响应。""" @@ -72,8 +77,29 @@ class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, **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: - """处理 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": try: items = [] @@ -95,6 +121,7 @@ class Handler(SimpleHTTPRequestHandler): def do_POST(self) -> None: """处理 POST 请求,目前仅支持 /api/tts。""" + self._track_visitor() if self.path != "/api/tts": self._send_json({"error": "not found"}, status=404) return @@ -119,8 +146,10 @@ class Handler(SimpleHTTPRequestHandler): cache_key = hashlib.sha256(f"{TTS_MODEL}|{voice}|{text}".encode("utf-8")).hexdigest()[:24] cache_file = CACHE_DIR / f"{cache_key}.wav" + STATS["tts_requests"] += 1 try: if cache_file.exists(): + STATS["cache_hits"] += 1 audio = cache_file.read_bytes() else: audio = synthesize(text, voice) diff --git a/style.css b/style.css index d693fa8..5a9ea3c 100644 --- a/style.css +++ b/style.css @@ -714,3 +714,64 @@ body.night .seg.playing { background: rgba(201, 106, 92, 0.16); color: #f0c9c2; flex: none; } .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; } diff --git a/sw.js b/sw.js index 00fe7f5..6768210 100644 --- a/sw.js +++ b/sw.js @@ -4,7 +4,7 @@ * - /api/tts 不拦截,直接透传网络 */ 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) => { e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));