index.html 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>lark2lark 管理面板</title>
  7. <style>
  8. :root {
  9. --bg: #0f1115;
  10. --panel: #1a1d24;
  11. --panel-2: #22262f;
  12. --border: #2d3139;
  13. --text: #e4e6eb;
  14. --text-dim: #8a8f9a;
  15. --accent: #4a9eff;
  16. --accent-hover: #3a8eef;
  17. --green: #3ec47e;
  18. --red: #ef5350;
  19. --yellow: #f5a623;
  20. --code-bg: #0a0c10;
  21. }
  22. * { box-sizing: border-box; margin: 0; padding: 0; }
  23. body {
  24. font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
  25. background: var(--bg);
  26. color: var(--text);
  27. font-size: 14px;
  28. line-height: 1.5;
  29. }
  30. /* 登录页 */
  31. #login-view {
  32. max-width: 360px;
  33. margin: 120px auto;
  34. padding: 32px;
  35. background: var(--panel);
  36. border: 1px solid var(--border);
  37. border-radius: 8px;
  38. }
  39. #login-view h1 { font-size: 20px; margin-bottom: 24px; text-align: center; }
  40. #login-view input {
  41. width: 100%; padding: 10px 12px;
  42. background: var(--panel-2); border: 1px solid var(--border);
  43. border-radius: 4px; color: var(--text); font-size: 14px;
  44. }
  45. #login-view input:focus { outline: none; border-color: var(--accent); }
  46. #login-view button {
  47. width: 100%; margin-top: 16px; padding: 10px;
  48. background: var(--accent); color: #fff; border: none;
  49. border-radius: 4px; cursor: pointer; font-size: 14px;
  50. }
  51. #login-view button:hover { background: var(--accent-hover); }
  52. #login-error { color: var(--red); margin-top: 12px; min-height: 18px; font-size: 13px; }
  53. /* 主面板 */
  54. #main-view { display: none; }
  55. .topbar {
  56. display: flex; align-items: center; justify-content: space-between;
  57. padding: 12px 24px; background: var(--panel); border-bottom: 1px solid var(--border);
  58. }
  59. .topbar h1 { font-size: 16px; font-weight: 600; }
  60. .topbar .actions { display: flex; gap: 12px; align-items: center; }
  61. .tabs { display: flex; gap: 4px; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
  62. .tab {
  63. padding: 12px 20px; cursor: pointer; color: var(--text-dim);
  64. border-bottom: 2px solid transparent; font-size: 14px;
  65. }
  66. .tab.active { color: var(--text); border-bottom-color: var(--accent); }
  67. .tab:hover { color: var(--text); }
  68. .content { padding: 24px; max-width: 960px; margin: 0 auto; }
  69. .tab-pane { display: none; }
  70. .tab-pane.active { display: block; }
  71. /* 配置表单 */
  72. .form-group { margin-bottom: 18px; }
  73. .form-group label { display: block; margin-bottom: 6px; color: var(--text-dim); font-size: 13px; }
  74. .form-group input, .form-group textarea, .form-group select {
  75. width: 100%; padding: 8px 10px;
  76. background: var(--panel-2); border: 1px solid var(--border);
  77. border-radius: 4px; color: var(--text); font-size: 14px;
  78. font-family: "Consolas", "Monaco", monospace;
  79. }
  80. .form-group textarea { min-height: 70px; resize: vertical; }
  81. .form-group input:focus, .form-group textarea:focus, .form-group select:focus {
  82. outline: none; border-color: var(--accent);
  83. }
  84. .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
  85. .form-hint { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
  86. .btn-primary {
  87. padding: 8px 20px; background: var(--accent); color: #fff;
  88. border: none; border-radius: 4px; cursor: pointer; font-size: 14px;
  89. }
  90. .btn-primary:hover { background: var(--accent-hover); }
  91. .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
  92. .btn-secondary {
  93. padding: 6px 14px; background: var(--panel-2); color: var(--text);
  94. border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 13px;
  95. }
  96. .btn-secondary:hover { border-color: var(--accent); }
  97. /* 状态卡片 */
  98. .status-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
  99. .status-card {
  100. padding: 16px; background: var(--panel); border: 1px solid var(--border);
  101. border-radius: 6px;
  102. }
  103. .status-card .label { font-size: 12px; color: var(--text-dim); margin-bottom: 8px; }
  104. .status-card .value { font-size: 22px; font-weight: 600; }
  105. .status-card .value.ok { color: var(--green); }
  106. .status-card .value.err { color: var(--red); }
  107. .status-card .value.warn { color: var(--yellow); }
  108. .badge {
  109. display: inline-block; padding: 2px 8px; border-radius: 10px;
  110. font-size: 12px; font-weight: 600;
  111. }
  112. .badge.ok { background: rgba(62,196,126,0.15); color: var(--green); }
  113. .badge.err { background: rgba(239,83,80,0.15); color: var(--red); }
  114. .badge.warn { background: rgba(245,166,35,0.15); color: var(--yellow); }
  115. /* 日志 */
  116. #log-filter { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
  117. #log-filter label { color: var(--text-dim); font-size: 13px; }
  118. #log-container {
  119. background: var(--code-bg); border: 1px solid var(--border);
  120. border-radius: 6px; padding: 12px; height: 60vh; overflow-y: auto;
  121. font-family: "Consolas", "Monaco", monospace; font-size: 12.5px; line-height: 1.6;
  122. }
  123. .log-line { white-space: pre-wrap; word-break: break-all; padding: 1px 0; }
  124. .log-line .level-DEBUG { color: #6a737d; }
  125. .log-line .level-INFO { color: var(--text); }
  126. .log-line .level-WARNING { color: var(--yellow); }
  127. .log-line .level-ERROR { color: var(--red); }
  128. .log-line .level-CRITICAL { color: var(--red); font-weight: bold; }
  129. .toast {
  130. position: fixed; bottom: 24px; right: 24px; padding: 12px 20px;
  131. background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
  132. box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 1000;
  133. transition: opacity 0.3s; opacity: 0;
  134. }
  135. .toast.show { opacity: 1; }
  136. .toast.ok { border-left: 3px solid var(--green); }
  137. .toast.err { border-left: 3px solid var(--red); }
  138. </style>
  139. </head>
  140. <body>
  141. <div id="login-view">
  142. <h1>lark2lark 管理面板</h1>
  143. <form id="login-form">
  144. <input type="password" id="login-password" placeholder="面板密码" autocomplete="current-password">
  145. <button type="submit">登录</button>
  146. <div id="login-error"></div>
  147. </form>
  148. </div>
  149. <div id="main-view">
  150. <div class="topbar">
  151. <h1>lark2lark 管理面板</h1>
  152. <div class="actions">
  153. <button class="btn-secondary" onclick="restartBot()">重启 Bot</button>
  154. <button class="btn-secondary" onclick="logout()">退出登录</button>
  155. </div>
  156. </div>
  157. <div class="tabs">
  158. <div class="tab active" data-tab="config">配置</div>
  159. <div class="tab" data-tab="status">状态</div>
  160. <div class="tab" data-tab="logs">日志</div>
  161. </div>
  162. <div class="content">
  163. <div class="tab-pane active" id="pane-config">
  164. <form id="config-form">
  165. <div class="form-row">
  166. <div class="form-group">
  167. <label>App ID</label>
  168. <input type="text" id="cfg-app_id" autocomplete="off">
  169. </div>
  170. <div class="form-group">
  171. <label>App Secret(留 *** 表示不修改)</label>
  172. <input type="text" id="cfg-app_secret" autocomplete="off">
  173. </div>
  174. </div>
  175. <div class="form-group">
  176. <label>源群 chat_id 列表(每行一个)</label>
  177. <textarea id="cfg-source_chat_ids" placeholder="oc_xxx&#10;oc_yyy"></textarea>
  178. <div class="form-hint">只转发这些群的消息</div>
  179. </div>
  180. <div class="form-group">
  181. <label>目标群 chat_id 列表(每行一个)</label>
  182. <textarea id="cfg-target_chat_ids" placeholder="oc_zzz"></textarea>
  183. <div class="form-hint">消息转发到这些群</div>
  184. </div>
  185. <div class="form-row">
  186. <div class="form-group">
  187. <label>去重缓存大小</label>
  188. <input type="number" id="cfg-dedup_cache_size" min="100">
  189. </div>
  190. <div class="form-group">
  191. <label>最大 QPS(1-5)</label>
  192. <input type="number" id="cfg-max_qps" min="1" max="5">
  193. </div>
  194. </div>
  195. <div class="form-row">
  196. <div class="form-group">
  197. <label>失败重试次数</label>
  198. <input type="number" id="cfg-max_retry" min="0">
  199. </div>
  200. <div class="form-group">
  201. <label>日志级别</label>
  202. <select id="cfg-log_level">
  203. <option>DEBUG</option>
  204. <option>INFO</option>
  205. <option>WARNING</option>
  206. <option>ERROR</option>
  207. </select>
  208. </div>
  209. </div>
  210. <button type="submit" class="btn-primary" id="save-btn">保存并重启</button>
  211. </form>
  212. </div>
  213. <div class="tab-pane" id="pane-status">
  214. <div class="status-grid" id="status-grid"></div>
  215. </div>
  216. <div class="tab-pane" id="pane-logs">
  217. <div id="log-filter">
  218. <label>级别:</label>
  219. <label><input type="checkbox" id="lvl-DEBUG" checked> DEBUG</label>
  220. <label><input type="checkbox" id="lvl-INFO" checked> INFO</label>
  221. <label><input type="checkbox" id="lvl-WARNING" checked> WARNING</label>
  222. <label><input type="checkbox" id="lvl-ERROR" checked> ERROR</label>
  223. <button class="btn-secondary" onclick="clearLogs()">清屏</button>
  224. <label style="margin-left:auto"><input type="checkbox" id="autoscroll" checked> 自动滚动</label>
  225. </div>
  226. <div id="log-container"></div>
  227. </div>
  228. </div>
  229. </div>
  230. <div class="toast" id="toast"></div>
  231. <script>
  232. const $ = (id) => document.getElementById(id);
  233. let sse = null;
  234. let statusTimer = null;
  235. let savedConfig = {};
  236. // ---------- 登录 ----------
  237. async function checkAuth() {
  238. try {
  239. const r = await fetch('/api/config');
  240. if (r.status === 401) {
  241. showLogin();
  242. return;
  243. }
  244. showMain();
  245. } catch {
  246. showLogin();
  247. }
  248. }
  249. function showLogin() {
  250. $('login-view').style.display = 'block';
  251. $('main-view').style.display = 'none';
  252. }
  253. function showMain() {
  254. $('login-view').style.display = 'none';
  255. $('main-view').style.display = 'block';
  256. loadConfig();
  257. startStatusPolling();
  258. startLogStream();
  259. }
  260. $('login-form').addEventListener('submit', async (e) => {
  261. e.preventDefault();
  262. $('login-error').textContent = '';
  263. const pwd = $('login-password').value;
  264. try {
  265. const r = await fetch('/api/login', {
  266. method: 'POST',
  267. headers: {'Content-Type': 'application/json'},
  268. body: JSON.stringify({password: pwd}),
  269. });
  270. if (r.ok) {
  271. showMain();
  272. } else {
  273. const d = await r.json().catch(() => ({}));
  274. $('login-error').textContent = d.detail || '登录失败';
  275. }
  276. } catch (err) {
  277. $('login-error').textContent = '网络错误';
  278. }
  279. });
  280. async function logout() {
  281. await fetch('/api/logout', {method: 'POST'});
  282. showLogin();
  283. if (sse) { sse.close(); sse = null; }
  284. if (statusTimer) { clearInterval(statusTimer); statusTimer = null; }
  285. }
  286. // ---------- Tab 切换 ----------
  287. document.querySelectorAll('.tab').forEach(tab => {
  288. tab.addEventListener('click', () => {
  289. document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  290. document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
  291. tab.classList.add('active');
  292. $('pane-' + tab.dataset.tab).classList.add('active');
  293. });
  294. });
  295. // ---------- 配置 ----------
  296. async function loadConfig() {
  297. try {
  298. const r = await fetch('/api/config');
  299. if (!r.ok) return;
  300. const cfg = await r.json();
  301. savedConfig = cfg;
  302. $('cfg-app_id').value = cfg.app_id || '';
  303. $('cfg-app_secret').value = cfg.app_secret || ''; // ***
  304. $('cfg-source_chat_ids').value = (cfg.source_chat_ids || []).join('\n');
  305. $('cfg-target_chat_ids').value = (cfg.target_chat_ids || []).join('\n');
  306. $('cfg-dedup_cache_size').value = cfg.dedup_cache_size ?? 2000;
  307. $('cfg-max_qps').value = cfg.max_qps ?? 4;
  308. $('cfg-max_retry').value = cfg.max_retry ?? 2;
  309. $('cfg-log_level').value = cfg.log_level || 'INFO';
  310. } catch (err) {
  311. toast('加载配置失败: ' + err.message, 'err');
  312. }
  313. }
  314. $('config-form').addEventListener('submit', async (e) => {
  315. e.preventDefault();
  316. $('save-btn').disabled = true;
  317. const payload = {
  318. app_id: $('cfg-app_id').value.trim(),
  319. app_secret: $('cfg-app_secret').value.trim(),
  320. source_chat_ids: $('cfg-source_chat_ids').value.split('\n').map(s => s.trim()).filter(Boolean),
  321. target_chat_ids: $('cfg-target_chat_ids').value.split('\n').map(s => s.trim()).filter(Boolean),
  322. dedup_cache_size: parseInt($('cfg-dedup_cache_size').value, 10),
  323. max_qps: parseInt($('cfg-max_qps').value, 10),
  324. max_retry: parseInt($('cfg-max_retry').value, 10),
  325. log_level: $('cfg-log_level').value,
  326. };
  327. try {
  328. const r = await fetch('/api/config', {
  329. method: 'PUT',
  330. headers: {'Content-Type': 'application/json'},
  331. body: JSON.stringify(payload),
  332. });
  333. const d = await r.json().catch(() => ({}));
  334. if (r.ok) {
  335. toast(d.note || '已保存', 'ok');
  336. setTimeout(loadConfig, 3000);
  337. } else {
  338. toast('保存失败: ' + (d.detail || r.status), 'err');
  339. }
  340. } catch (err) {
  341. toast('保存失败: ' + err.message, 'err');
  342. } finally {
  343. $('save-btn').disabled = false;
  344. }
  345. });
  346. // ---------- 状态 ----------
  347. function startStatusPolling() {
  348. fetchStatus();
  349. statusTimer = setInterval(fetchStatus, 3000);
  350. }
  351. async function fetchStatus() {
  352. try {
  353. const r = await fetch('/api/status');
  354. if (!r.ok) return;
  355. const s = await r.json();
  356. const grid = $('status-grid');
  357. const alive = s.bot_alive;
  358. const wsClass = s.ws_connected ? 'ok' : (alive ? 'warn' : 'err');
  359. const wsText = s.ws_connected ? '已连接' : (alive ? '连接中' : '未运行');
  360. const upFmt = s.uptime ? formatUptime(s.uptime) : '-';
  361. grid.innerHTML = `
  362. <div class="status-card">
  363. <div class="label">Bot 进程</div>
  364. <div class="value ${alive ? 'ok' : 'err'}">${alive ? '运行中' : '已停止'}</div>
  365. </div>
  366. <div class="status-card">
  367. <div class="label">WebSocket</div>
  368. <div class="value ${wsClass}">${wsText}</div>
  369. </div>
  370. <div class="status-card">
  371. <div class="label">运行时长</div>
  372. <div class="value">${upFmt}</div>
  373. </div>
  374. <div class="status-card">
  375. <div class="label">队列积压</div>
  376. <div class="value ${s.queue_size > 50 ? 'warn' : ''}">${s.queue_size}</div>
  377. </div>
  378. <div class="status-card">
  379. <div class="label">去重缓存</div>
  380. <div class="value">${s.dedup_size}<span style="font-size:14px;color:var(--text-dim)"> / ${s.dedup_max}</span></div>
  381. </div>
  382. <div class="status-card">
  383. <div class="label">状态新鲜度</div>
  384. <div class="value ${s.status_fresh ? 'ok' : 'warn'}">${s.status_fresh ? '正常' : '失联'}</div>
  385. </div>
  386. `;
  387. } catch {}
  388. }
  389. function formatUptime(sec) {
  390. const d = Math.floor(sec / 86400);
  391. const h = Math.floor((sec % 86400) / 3600);
  392. const m = Math.floor((sec % 3600) / 60);
  393. if (d > 0) return `${d}d ${h}h`;
  394. if (h > 0) return `${h}h ${m}m`;
  395. return `${m}m`;
  396. }
  397. // ---------- 日志 SSE ----------
  398. function startLogStream() {
  399. if (sse) sse.close();
  400. // 先拉历史
  401. fetch('/api/logs/history').then(r => r.json()).then(entries => {
  402. entries.forEach(appendLog);
  403. // 再建立 SSE
  404. const lastSeq = entries.length ? entries[entries.length - 1].seq : 0;
  405. sse = new EventSource('/api/logs?since=' + lastSeq);
  406. sse.onmessage = (ev) => {
  407. try {
  408. const entry = JSON.parse(ev.data);
  409. appendLog(entry);
  410. } catch {}
  411. };
  412. sse.onerror = () => {
  413. // 浏览器会自动重连
  414. };
  415. });
  416. }
  417. function appendLog(entry) {
  418. const container = $('log-container');
  419. const line = document.createElement('div');
  420. line.className = 'log-line';
  421. line.dataset.level = entry.level;
  422. const ts = new Date(entry.ts * 1000).toLocaleTimeString('zh-CN', {hour12: false});
  423. line.innerHTML = `<span class="level-${entry.level}">[${ts}] [${entry.level}]</span> ${escapeHtml(entry.text)}`;
  424. container.appendChild(line);
  425. // 限制 DOM 条数
  426. while (container.children.length > 1000) container.removeChild(container.firstChild);
  427. if ($('autoscroll').checked) container.scrollTop = container.scrollHeight;
  428. }
  429. function clearLogs() {
  430. $('log-container').innerHTML = '';
  431. }
  432. function escapeHtml(s) {
  433. return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  434. }
  435. // 日志级别过滤
  436. ['DEBUG','INFO','WARNING','ERROR'].forEach(lvl => {
  437. $('lvl-' + lvl).addEventListener('change', applyLogFilter);
  438. });
  439. function applyLogFilter() {
  440. const enabled = {};
  441. ['DEBUG','INFO','WARNING','ERROR'].forEach(lvl => {
  442. enabled[lvl] = $('lvl-' + lvl).checked;
  443. });
  444. document.querySelectorAll('.log-line').forEach(line => {
  445. const lvl = line.dataset.level;
  446. // CRITICAL 归到 ERROR
  447. const key = lvl === 'CRITICAL' ? 'ERROR' : lvl;
  448. line.style.display = enabled[key] === false ? 'none' : '';
  449. });
  450. }
  451. // ---------- 操作 ----------
  452. async function restartBot() {
  453. if (!confirm('确认重启 Bot?')) return;
  454. try {
  455. const r = await fetch('/api/bot/restart', {method: 'POST'});
  456. const d = await r.json();
  457. toast(d.note || '已重启', 'ok');
  458. } catch (err) {
  459. toast('重启失败: ' + err.message, 'err');
  460. }
  461. }
  462. function toast(msg, type) {
  463. const t = $('toast');
  464. t.textContent = msg;
  465. t.className = 'toast show ' + (type || '');
  466. setTimeout(() => { t.className = 'toast ' + (type || ''); }, 3000);
  467. }
  468. // 启动
  469. checkAuth();
  470. </script>
  471. </body>
  472. </html>