| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502 |
- <!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>
|