|
@@ -0,0 +1,502 @@
|
|
|
|
|
+<!DOCTYPE html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="UTF-8">
|
|
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+<title>lark2lark 管理面板</title>
|
|
|
|
|
+<style>
|
|
|
|
|
+ :root {
|
|
|
|
|
+ --bg: #0f1115;
|
|
|
|
|
+ --panel: #1a1d24;
|
|
|
|
|
+ --panel-2: #22262f;
|
|
|
|
|
+ --border: #2d3139;
|
|
|
|
|
+ --text: #e4e6eb;
|
|
|
|
|
+ --text-dim: #8a8f9a;
|
|
|
|
|
+ --accent: #4a9eff;
|
|
|
|
|
+ --accent-hover: #3a8eef;
|
|
|
|
|
+ --green: #3ec47e;
|
|
|
|
|
+ --red: #ef5350;
|
|
|
|
|
+ --yellow: #f5a623;
|
|
|
|
|
+ --code-bg: #0a0c10;
|
|
|
|
|
+ }
|
|
|
|
|
+ * { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
+ body {
|
|
|
|
|
+ font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
|
|
|
+ background: var(--bg);
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ line-height: 1.5;
|
|
|
|
|
+ }
|
|
|
|
|
+ /* 登录页 */
|
|
|
|
|
+ #login-view {
|
|
|
|
|
+ max-width: 360px;
|
|
|
|
|
+ margin: 120px auto;
|
|
|
|
|
+ padding: 32px;
|
|
|
|
|
+ background: var(--panel);
|
|
|
|
|
+ border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ }
|
|
|
|
|
+ #login-view h1 { font-size: 20px; margin-bottom: 24px; text-align: center; }
|
|
|
|
|
+ #login-view input {
|
|
|
|
|
+ width: 100%; padding: 10px 12px;
|
|
|
|
|
+ background: var(--panel-2); border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 4px; color: var(--text); font-size: 14px;
|
|
|
|
|
+ }
|
|
|
|
|
+ #login-view input:focus { outline: none; border-color: var(--accent); }
|
|
|
|
|
+ #login-view button {
|
|
|
|
|
+ width: 100%; margin-top: 16px; padding: 10px;
|
|
|
|
|
+ background: var(--accent); color: #fff; border: none;
|
|
|
|
|
+ border-radius: 4px; cursor: pointer; font-size: 14px;
|
|
|
|
|
+ }
|
|
|
|
|
+ #login-view button:hover { background: var(--accent-hover); }
|
|
|
|
|
+ #login-error { color: var(--red); margin-top: 12px; min-height: 18px; font-size: 13px; }
|
|
|
|
|
+
|
|
|
|
|
+ /* 主面板 */
|
|
|
|
|
+ #main-view { display: none; }
|
|
|
|
|
+ .topbar {
|
|
|
|
|
+ display: flex; align-items: center; justify-content: space-between;
|
|
|
|
|
+ padding: 12px 24px; background: var(--panel); border-bottom: 1px solid var(--border);
|
|
|
|
|
+ }
|
|
|
|
|
+ .topbar h1 { font-size: 16px; font-weight: 600; }
|
|
|
|
|
+ .topbar .actions { display: flex; gap: 12px; align-items: center; }
|
|
|
|
|
+ .tabs { display: flex; gap: 4px; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
|
|
|
|
|
+ .tab {
|
|
|
|
|
+ padding: 12px 20px; cursor: pointer; color: var(--text-dim);
|
|
|
|
|
+ border-bottom: 2px solid transparent; font-size: 14px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
|
|
|
|
+ .tab:hover { color: var(--text); }
|
|
|
|
|
+ .content { padding: 24px; max-width: 960px; margin: 0 auto; }
|
|
|
|
|
+ .tab-pane { display: none; }
|
|
|
|
|
+ .tab-pane.active { display: block; }
|
|
|
|
|
+
|
|
|
|
|
+ /* 配置表单 */
|
|
|
|
|
+ .form-group { margin-bottom: 18px; }
|
|
|
|
|
+ .form-group label { display: block; margin-bottom: 6px; color: var(--text-dim); font-size: 13px; }
|
|
|
|
|
+ .form-group input, .form-group textarea, .form-group select {
|
|
|
|
|
+ width: 100%; padding: 8px 10px;
|
|
|
|
|
+ background: var(--panel-2); border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 4px; color: var(--text); font-size: 14px;
|
|
|
|
|
+ font-family: "Consolas", "Monaco", monospace;
|
|
|
|
|
+ }
|
|
|
|
|
+ .form-group textarea { min-height: 70px; resize: vertical; }
|
|
|
|
|
+ .form-group input:focus, .form-group textarea:focus, .form-group select:focus {
|
|
|
|
|
+ outline: none; border-color: var(--accent);
|
|
|
|
|
+ }
|
|
|
|
|
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
|
|
|
+ .form-hint { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
|
|
|
|
|
+ .btn-primary {
|
|
|
|
|
+ padding: 8px 20px; background: var(--accent); color: #fff;
|
|
|
|
|
+ border: none; border-radius: 4px; cursor: pointer; font-size: 14px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .btn-primary:hover { background: var(--accent-hover); }
|
|
|
|
|
+ .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
|
+ .btn-secondary {
|
|
|
|
|
+ padding: 6px 14px; background: var(--panel-2); color: var(--text);
|
|
|
|
|
+ border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 13px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .btn-secondary:hover { border-color: var(--accent); }
|
|
|
|
|
+
|
|
|
|
|
+ /* 状态卡片 */
|
|
|
|
|
+ .status-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
|
|
|
|
|
+ .status-card {
|
|
|
|
|
+ padding: 16px; background: var(--panel); border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .status-card .label { font-size: 12px; color: var(--text-dim); margin-bottom: 8px; }
|
|
|
|
|
+ .status-card .value { font-size: 22px; font-weight: 600; }
|
|
|
|
|
+ .status-card .value.ok { color: var(--green); }
|
|
|
|
|
+ .status-card .value.err { color: var(--red); }
|
|
|
|
|
+ .status-card .value.warn { color: var(--yellow); }
|
|
|
|
|
+ .badge {
|
|
|
|
|
+ display: inline-block; padding: 2px 8px; border-radius: 10px;
|
|
|
|
|
+ font-size: 12px; font-weight: 600;
|
|
|
|
|
+ }
|
|
|
|
|
+ .badge.ok { background: rgba(62,196,126,0.15); color: var(--green); }
|
|
|
|
|
+ .badge.err { background: rgba(239,83,80,0.15); color: var(--red); }
|
|
|
|
|
+ .badge.warn { background: rgba(245,166,35,0.15); color: var(--yellow); }
|
|
|
|
|
+
|
|
|
|
|
+ /* 日志 */
|
|
|
|
|
+ #log-filter { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
|
|
|
|
+ #log-filter label { color: var(--text-dim); font-size: 13px; }
|
|
|
|
|
+ #log-container {
|
|
|
|
|
+ background: var(--code-bg); border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 6px; padding: 12px; height: 60vh; overflow-y: auto;
|
|
|
|
|
+ font-family: "Consolas", "Monaco", monospace; font-size: 12.5px; line-height: 1.6;
|
|
|
|
|
+ }
|
|
|
|
|
+ .log-line { white-space: pre-wrap; word-break: break-all; padding: 1px 0; }
|
|
|
|
|
+ .log-line .level-DEBUG { color: #6a737d; }
|
|
|
|
|
+ .log-line .level-INFO { color: var(--text); }
|
|
|
|
|
+ .log-line .level-WARNING { color: var(--yellow); }
|
|
|
|
|
+ .log-line .level-ERROR { color: var(--red); }
|
|
|
|
|
+ .log-line .level-CRITICAL { color: var(--red); font-weight: bold; }
|
|
|
|
|
+
|
|
|
|
|
+ .toast {
|
|
|
|
|
+ position: fixed; bottom: 24px; right: 24px; padding: 12px 20px;
|
|
|
|
|
+ background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
|
|
|
|
|
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 1000;
|
|
|
|
|
+ transition: opacity 0.3s; opacity: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .toast.show { opacity: 1; }
|
|
|
|
|
+ .toast.ok { border-left: 3px solid var(--green); }
|
|
|
|
|
+ .toast.err { border-left: 3px solid var(--red); }
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+
|
|
|
|
|
+<div id="login-view">
|
|
|
|
|
+ <h1>lark2lark 管理面板</h1>
|
|
|
|
|
+ <form id="login-form">
|
|
|
|
|
+ <input type="password" id="login-password" placeholder="面板密码" autocomplete="current-password">
|
|
|
|
|
+ <button type="submit">登录</button>
|
|
|
|
|
+ <div id="login-error"></div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div id="main-view">
|
|
|
|
|
+ <div class="topbar">
|
|
|
|
|
+ <h1>lark2lark 管理面板</h1>
|
|
|
|
|
+ <div class="actions">
|
|
|
|
|
+ <button class="btn-secondary" onclick="restartBot()">重启 Bot</button>
|
|
|
|
|
+ <button class="btn-secondary" onclick="logout()">退出登录</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="tabs">
|
|
|
|
|
+ <div class="tab active" data-tab="config">配置</div>
|
|
|
|
|
+ <div class="tab" data-tab="status">状态</div>
|
|
|
|
|
+ <div class="tab" data-tab="logs">日志</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="content">
|
|
|
|
|
+ <div class="tab-pane active" id="pane-config">
|
|
|
|
|
+ <form id="config-form">
|
|
|
|
|
+ <div class="form-row">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>App ID</label>
|
|
|
|
|
+ <input type="text" id="cfg-app_id" autocomplete="off">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>App Secret(留 *** 表示不修改)</label>
|
|
|
|
|
+ <input type="text" id="cfg-app_secret" autocomplete="off">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>源群 chat_id 列表(每行一个)</label>
|
|
|
|
|
+ <textarea id="cfg-source_chat_ids" placeholder="oc_xxx oc_yyy"></textarea>
|
|
|
|
|
+ <div class="form-hint">只转发这些群的消息</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>目标群 chat_id 列表(每行一个)</label>
|
|
|
|
|
+ <textarea id="cfg-target_chat_ids" placeholder="oc_zzz"></textarea>
|
|
|
|
|
+ <div class="form-hint">消息转发到这些群</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-row">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>去重缓存大小</label>
|
|
|
|
|
+ <input type="number" id="cfg-dedup_cache_size" min="100">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>最大 QPS(1-5)</label>
|
|
|
|
|
+ <input type="number" id="cfg-max_qps" min="1" max="5">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-row">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>失败重试次数</label>
|
|
|
|
|
+ <input type="number" id="cfg-max_retry" min="0">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>日志级别</label>
|
|
|
|
|
+ <select id="cfg-log_level">
|
|
|
|
|
+ <option>DEBUG</option>
|
|
|
|
|
+ <option>INFO</option>
|
|
|
|
|
+ <option>WARNING</option>
|
|
|
|
|
+ <option>ERROR</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button type="submit" class="btn-primary" id="save-btn">保存并重启</button>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="tab-pane" id="pane-status">
|
|
|
|
|
+ <div class="status-grid" id="status-grid"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="tab-pane" id="pane-logs">
|
|
|
|
|
+ <div id="log-filter">
|
|
|
|
|
+ <label>级别:</label>
|
|
|
|
|
+ <label><input type="checkbox" id="lvl-DEBUG" checked> DEBUG</label>
|
|
|
|
|
+ <label><input type="checkbox" id="lvl-INFO" checked> INFO</label>
|
|
|
|
|
+ <label><input type="checkbox" id="lvl-WARNING" checked> WARNING</label>
|
|
|
|
|
+ <label><input type="checkbox" id="lvl-ERROR" checked> ERROR</label>
|
|
|
|
|
+ <button class="btn-secondary" onclick="clearLogs()">清屏</button>
|
|
|
|
|
+ <label style="margin-left:auto"><input type="checkbox" id="autoscroll" checked> 自动滚动</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="log-container"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div class="toast" id="toast"></div>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+const $ = (id) => document.getElementById(id);
|
|
|
|
|
+let sse = null;
|
|
|
|
|
+let statusTimer = null;
|
|
|
|
|
+let savedConfig = {};
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 登录 ----------
|
|
|
|
|
+async function checkAuth() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch('/api/config');
|
|
|
|
|
+ if (r.status === 401) {
|
|
|
|
|
+ showLogin();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ showMain();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ showLogin();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function showLogin() {
|
|
|
|
|
+ $('login-view').style.display = 'block';
|
|
|
|
|
+ $('main-view').style.display = 'none';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function showMain() {
|
|
|
|
|
+ $('login-view').style.display = 'none';
|
|
|
|
|
+ $('main-view').style.display = 'block';
|
|
|
|
|
+ loadConfig();
|
|
|
|
|
+ startStatusPolling();
|
|
|
|
|
+ startLogStream();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+$('login-form').addEventListener('submit', async (e) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ $('login-error').textContent = '';
|
|
|
|
|
+ const pwd = $('login-password').value;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch('/api/login', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {'Content-Type': 'application/json'},
|
|
|
|
|
+ body: JSON.stringify({password: pwd}),
|
|
|
|
|
+ });
|
|
|
|
|
+ if (r.ok) {
|
|
|
|
|
+ showMain();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const d = await r.json().catch(() => ({}));
|
|
|
|
|
+ $('login-error').textContent = d.detail || '登录失败';
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ $('login-error').textContent = '网络错误';
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+async function logout() {
|
|
|
|
|
+ await fetch('/api/logout', {method: 'POST'});
|
|
|
|
|
+ showLogin();
|
|
|
|
|
+ if (sse) { sse.close(); sse = null; }
|
|
|
|
|
+ if (statusTimer) { clearInterval(statusTimer); statusTimer = null; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- Tab 切换 ----------
|
|
|
|
|
+document.querySelectorAll('.tab').forEach(tab => {
|
|
|
|
|
+ tab.addEventListener('click', () => {
|
|
|
|
|
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
|
|
|
+ document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
|
|
|
|
+ tab.classList.add('active');
|
|
|
|
|
+ $('pane-' + tab.dataset.tab).classList.add('active');
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 配置 ----------
|
|
|
|
|
+async function loadConfig() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch('/api/config');
|
|
|
|
|
+ if (!r.ok) return;
|
|
|
|
|
+ const cfg = await r.json();
|
|
|
|
|
+ savedConfig = cfg;
|
|
|
|
|
+ $('cfg-app_id').value = cfg.app_id || '';
|
|
|
|
|
+ $('cfg-app_secret').value = cfg.app_secret || ''; // ***
|
|
|
|
|
+ $('cfg-source_chat_ids').value = (cfg.source_chat_ids || []).join('\n');
|
|
|
|
|
+ $('cfg-target_chat_ids').value = (cfg.target_chat_ids || []).join('\n');
|
|
|
|
|
+ $('cfg-dedup_cache_size').value = cfg.dedup_cache_size ?? 2000;
|
|
|
|
|
+ $('cfg-max_qps').value = cfg.max_qps ?? 4;
|
|
|
|
|
+ $('cfg-max_retry').value = cfg.max_retry ?? 2;
|
|
|
|
|
+ $('cfg-log_level').value = cfg.log_level || 'INFO';
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ toast('加载配置失败: ' + err.message, 'err');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+$('config-form').addEventListener('submit', async (e) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ $('save-btn').disabled = true;
|
|
|
|
|
+ const payload = {
|
|
|
|
|
+ app_id: $('cfg-app_id').value.trim(),
|
|
|
|
|
+ app_secret: $('cfg-app_secret').value.trim(),
|
|
|
|
|
+ source_chat_ids: $('cfg-source_chat_ids').value.split('\n').map(s => s.trim()).filter(Boolean),
|
|
|
|
|
+ target_chat_ids: $('cfg-target_chat_ids').value.split('\n').map(s => s.trim()).filter(Boolean),
|
|
|
|
|
+ dedup_cache_size: parseInt($('cfg-dedup_cache_size').value, 10),
|
|
|
|
|
+ max_qps: parseInt($('cfg-max_qps').value, 10),
|
|
|
|
|
+ max_retry: parseInt($('cfg-max_retry').value, 10),
|
|
|
|
|
+ log_level: $('cfg-log_level').value,
|
|
|
|
|
+ };
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch('/api/config', {
|
|
|
|
|
+ method: 'PUT',
|
|
|
|
|
+ headers: {'Content-Type': 'application/json'},
|
|
|
|
|
+ body: JSON.stringify(payload),
|
|
|
|
|
+ });
|
|
|
|
|
+ const d = await r.json().catch(() => ({}));
|
|
|
|
|
+ if (r.ok) {
|
|
|
|
|
+ toast(d.note || '已保存', 'ok');
|
|
|
|
|
+ setTimeout(loadConfig, 3000);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ toast('保存失败: ' + (d.detail || r.status), 'err');
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ toast('保存失败: ' + err.message, 'err');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ $('save-btn').disabled = false;
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 状态 ----------
|
|
|
|
|
+function startStatusPolling() {
|
|
|
|
|
+ fetchStatus();
|
|
|
|
|
+ statusTimer = setInterval(fetchStatus, 3000);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function fetchStatus() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch('/api/status');
|
|
|
|
|
+ if (!r.ok) return;
|
|
|
|
|
+ const s = await r.json();
|
|
|
|
|
+ const grid = $('status-grid');
|
|
|
|
|
+ const alive = s.bot_alive;
|
|
|
|
|
+ const wsClass = s.ws_connected ? 'ok' : (alive ? 'warn' : 'err');
|
|
|
|
|
+ const wsText = s.ws_connected ? '已连接' : (alive ? '连接中' : '未运行');
|
|
|
|
|
+ const upFmt = s.uptime ? formatUptime(s.uptime) : '-';
|
|
|
|
|
+ grid.innerHTML = `
|
|
|
|
|
+ <div class="status-card">
|
|
|
|
|
+ <div class="label">Bot 进程</div>
|
|
|
|
|
+ <div class="value ${alive ? 'ok' : 'err'}">${alive ? '运行中' : '已停止'}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="status-card">
|
|
|
|
|
+ <div class="label">WebSocket</div>
|
|
|
|
|
+ <div class="value ${wsClass}">${wsText}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="status-card">
|
|
|
|
|
+ <div class="label">运行时长</div>
|
|
|
|
|
+ <div class="value">${upFmt}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="status-card">
|
|
|
|
|
+ <div class="label">队列积压</div>
|
|
|
|
|
+ <div class="value ${s.queue_size > 50 ? 'warn' : ''}">${s.queue_size}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="status-card">
|
|
|
|
|
+ <div class="label">去重缓存</div>
|
|
|
|
|
+ <div class="value">${s.dedup_size}<span style="font-size:14px;color:var(--text-dim)"> / ${s.dedup_max}</span></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="status-card">
|
|
|
|
|
+ <div class="label">状态新鲜度</div>
|
|
|
|
|
+ <div class="value ${s.status_fresh ? 'ok' : 'warn'}">${s.status_fresh ? '正常' : '失联'}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatUptime(sec) {
|
|
|
|
|
+ const d = Math.floor(sec / 86400);
|
|
|
|
|
+ const h = Math.floor((sec % 86400) / 3600);
|
|
|
|
|
+ const m = Math.floor((sec % 3600) / 60);
|
|
|
|
|
+ if (d > 0) return `${d}d ${h}h`;
|
|
|
|
|
+ if (h > 0) return `${h}h ${m}m`;
|
|
|
|
|
+ return `${m}m`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 日志 SSE ----------
|
|
|
|
|
+function startLogStream() {
|
|
|
|
|
+ if (sse) sse.close();
|
|
|
|
|
+ // 先拉历史
|
|
|
|
|
+ fetch('/api/logs/history').then(r => r.json()).then(entries => {
|
|
|
|
|
+ entries.forEach(appendLog);
|
|
|
|
|
+ // 再建立 SSE
|
|
|
|
|
+ const lastSeq = entries.length ? entries[entries.length - 1].seq : 0;
|
|
|
|
|
+ sse = new EventSource('/api/logs?since=' + lastSeq);
|
|
|
|
|
+ sse.onmessage = (ev) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const entry = JSON.parse(ev.data);
|
|
|
|
|
+ appendLog(entry);
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+ };
|
|
|
|
|
+ sse.onerror = () => {
|
|
|
|
|
+ // 浏览器会自动重连
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function appendLog(entry) {
|
|
|
|
|
+ const container = $('log-container');
|
|
|
|
|
+ const line = document.createElement('div');
|
|
|
|
|
+ line.className = 'log-line';
|
|
|
|
|
+ line.dataset.level = entry.level;
|
|
|
|
|
+ const ts = new Date(entry.ts * 1000).toLocaleTimeString('zh-CN', {hour12: false});
|
|
|
|
|
+ line.innerHTML = `<span class="level-${entry.level}">[${ts}] [${entry.level}]</span> ${escapeHtml(entry.text)}`;
|
|
|
|
|
+ container.appendChild(line);
|
|
|
|
|
+ // 限制 DOM 条数
|
|
|
|
|
+ while (container.children.length > 1000) container.removeChild(container.firstChild);
|
|
|
|
|
+ if ($('autoscroll').checked) container.scrollTop = container.scrollHeight;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearLogs() {
|
|
|
|
|
+ $('log-container').innerHTML = '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function escapeHtml(s) {
|
|
|
|
|
+ return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 日志级别过滤
|
|
|
|
|
+['DEBUG','INFO','WARNING','ERROR'].forEach(lvl => {
|
|
|
|
|
+ $('lvl-' + lvl).addEventListener('change', applyLogFilter);
|
|
|
|
|
+});
|
|
|
|
|
+function applyLogFilter() {
|
|
|
|
|
+ const enabled = {};
|
|
|
|
|
+ ['DEBUG','INFO','WARNING','ERROR'].forEach(lvl => {
|
|
|
|
|
+ enabled[lvl] = $('lvl-' + lvl).checked;
|
|
|
|
|
+ });
|
|
|
|
|
+ document.querySelectorAll('.log-line').forEach(line => {
|
|
|
|
|
+ const lvl = line.dataset.level;
|
|
|
|
|
+ // CRITICAL 归到 ERROR
|
|
|
|
|
+ const key = lvl === 'CRITICAL' ? 'ERROR' : lvl;
|
|
|
|
|
+ line.style.display = enabled[key] === false ? 'none' : '';
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 操作 ----------
|
|
|
|
|
+async function restartBot() {
|
|
|
|
|
+ if (!confirm('确认重启 Bot?')) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch('/api/bot/restart', {method: 'POST'});
|
|
|
|
|
+ const d = await r.json();
|
|
|
|
|
+ toast(d.note || '已重启', 'ok');
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ toast('重启失败: ' + err.message, 'err');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toast(msg, type) {
|
|
|
|
|
+ const t = $('toast');
|
|
|
|
|
+ t.textContent = msg;
|
|
|
|
|
+ t.className = 'toast show ' + (type || '');
|
|
|
|
|
+ setTimeout(() => { t.className = 'toast ' + (type || ''); }, 3000);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 启动
|
|
|
|
|
+checkAuth();
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|