# -*- coding: utf-8 -*- r'''中转站管理后台的页面渲染(独立模块)。 为什么拆出来: 原来这一大坨内联在 relay.py 里(几百行 f-string + CSS + JS), 改一处样式要在一堆转义的花括号里找,改不动也没法看。 拆成模块后:CSS 是普通字符串、HTML 用普通格式化,清爽多了。 设计目标(用户反馈"难看的要死"): * 和官网共用一套设计语言:白底、浅灰面板、细边框、克制的蓝 * 信息分层次: 顶部栏(我是谁、什么状态) -> 公网地址(最该被看到、要复制的东西,给最大权重) -> 四个数字(一眼看全局) -> 设备表(主内容) -> 工具卡(体检、下载,次要,放最后) * 表格:状态用圆点+文字,配对码等宽字体,行悬停高亮 * 空状态要友好,不是干巴巴一句"没有数据" 功能一个都不能少(对应用户能做的事): 单台删除 / 勾选批量删除 / 删除全部 / 复制公网地址 / 网络体检 / 下载最新版 / 自动刷新开关 ''' from __future__ import annotations # ---------------- 设计令牌(和官网一致) ---------------- BG = "#ffffff" SURFACE = "#fafbfc" BORDER = "#e6e8eb" BORDER_SOFT = "#f0f2f5" TEXT = "#1a1d21" MUTED = "#6b7280" FAINT = "#9ca3af" ACCENT = "#2563eb" OK = "#16a34a" WARN = "#d97706" DANGER = "#dc2626" TITLE = "ykxmz 中转站" ADMIN_CSS = """ *{box-sizing:border-box;margin:0;padding:0} :root{ --bg:#ffffff; --surface:#fafbfc; --border:#e6e8eb; --border-soft:#f0f2f5; --fg:#1a1d21; --muted:#6b7280; --faint:#9ca3af; --accent:#2563eb; --ok:#16a34a; --warn:#d97706; --danger:#dc2626; } html,body{max-width:100%%;overflow-x:hidden} body{background:var(--bg);color:var(--fg);line-height:1.6; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei', 'PingFang SC',sans-serif;-webkit-font-smoothing:antialiased} a{color:inherit} .wrap{width:100%%;max-width:1040px;margin:0 auto;padding:0 20px 64px} /* ---------- 吸顶顶栏 ---------- */ .top{position:sticky;top:0;z-index:40;background:rgba(255,255,255,.88); backdrop-filter:saturate(180%%) blur(12px);border-bottom:1px solid var(--border)} /* * ⚠ 别用固定 height。 * 原来写的是 .top-in{height:62px},但里面品牌是「标题 + 副标题」两行、 * 右边还有两行版本信息 —— 实际内容比 62px 高, * flex 就把内容往上顶出去了:**标题被切掉、运行中徽章压在字上** * (用户截图里就是这样)。 * 改成 padding 撑开,内容多高就是多高。 */ .top-in{display:flex;align-items:center;gap:12px; padding-top:13px;padding-bottom:13px;min-height:36px} .logo{width:32px;height:32px;border-radius:9px;flex:none; background:linear-gradient(140deg,#2563eb,#1d4ed8); display:flex;align-items:center;justify-content:center; color:#fff;font-weight:700;font-size:12px;letter-spacing:-.3px} .brand{flex:none;min-width:0} .brand b{display:block;font-size:15px;font-weight:650;letter-spacing:-.2px; line-height:1.35;white-space:nowrap} .brand span{display:block;color:var(--faint);font-size:11px; line-height:1.4;white-space:nowrap} .state{margin-left:8px;flex:none;display:inline-flex;align-items:center;gap:6px; padding:3px 10px;border-radius:999px;background:#f0fdf4; color:#15803d;font-size:11px;font-weight:600;white-space:nowrap} .state i{width:6px;height:6px;border-radius:50%%;background:#22c55e; display:block;flex:none} .top-right{margin-left:auto;text-align:right;font-size:11px; color:var(--faint);line-height:1.65;min-width:0} .top-right b{color:var(--muted);font-weight:500} .logout{color:var(--muted);text-decoration:none; border-bottom:1px dashed var(--border);transition:.15s} .logout:hover{color:var(--danger);border-bottom-color:var(--danger)} /* 窄屏:状态徽章换行,别和标题挤 */ @media(max-width:640px){ .top-in{flex-wrap:wrap;gap:8px} .state{margin-left:auto} } /* ---------- 公网地址(最重要,给最大权重)---------- */ .hero{padding:30px 0 26px} .hero h2{font-size:13px;font-weight:600;color:var(--muted); margin-bottom:10px;letter-spacing:.2px} .urlbox{display:flex;align-items:stretch;gap:10px;flex-wrap:wrap} .url{flex:1;min-width:240px;display:flex;align-items:center; font:600 16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; color:var(--accent);word-break:break-all; background:var(--surface);border:1px solid var(--border); border-radius:11px;padding:14px 16px} .url.empty{color:var(--faint);font-weight:400;font-size:14px} .hint{color:var(--faint);font-size:12px;margin-top:9px} /* ---------- 数字卡 ---------- */ .nums{display:grid;gap:10px;grid-template-columns:repeat(2,1fr); margin-bottom:30px} @media(min-width:640px){.nums{grid-template-columns:repeat(4,1fr)}} .num{background:var(--surface);border:1px solid var(--border); border-radius:12px;padding:15px 16px} .num .v{font-size:25px;font-weight:700;letter-spacing:-.7px; line-height:1.1;font-variant-numeric:tabular-nums} .num .k{color:var(--muted);font-size:12px;margin-top:3px} .num.live{background:#f0fdf4;border-color:#bbf7d0} .num.live .v{color:#15803d} .num.busy{background:#fffbeb;border-color:#fde68a} .num.busy .v{color:#b45309} /* ---------- 区块标题 ---------- */ .sec{display:flex;align-items:center;gap:12px;margin-bottom:12px} .sec h3{font-size:15px;font-weight:650;letter-spacing:-.2px} .sec .right{margin-left:auto;display:flex;align-items:center;gap:12px} /* ---------- 表格 ---------- */ .card{background:#fff;border:1px solid var(--border);border-radius:13px; overflow:hidden} table{width:100%%;border-collapse:collapse;font-size:13px} thead th{background:var(--surface);text-align:left;font-weight:600; color:var(--muted);font-size:11.5px;letter-spacing:.3px; padding:11px 14px;border-bottom:1px solid var(--border); white-space:nowrap} tbody td{padding:13px 14px;border-bottom:1px solid var(--border-soft); vertical-align:middle} tbody tr:last-child td{border-bottom:none} tbody tr:hover td{background:#fbfcfd} tbody tr.picked td{background:#eff6ff} .dot{width:7px;height:7px;border-radius:50%%;display:inline-block; margin-right:7px;flex:none} .dot.on{background:#22c55e} .dot.off{background:#d1d5db} .devname{display:flex;align-items:center;font-weight:600} .devid{color:var(--faint);font-size:10.5px;margin-top:2px; font-family:ui-monospace,Consolas,monospace; overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px} code{font:600 13px/1 ui-monospace,Consolas,monospace; background:var(--surface);border:1px solid var(--border); border-radius:6px;padding:4px 8px;letter-spacing:.5px} .pill{padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600} .pill.on{background:#dcfce7;color:#15803d} .pill.off{background:#f3f4f6;color:#6b7280} .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px; color:var(--muted)} .ops{text-align:right} .empty{padding:56px 20px;text-align:center} .empty .big{font-size:15px;font-weight:600;margin-bottom:8px} .empty p{color:var(--muted);font-size:13px;max-width:340px;margin:0 auto} .empty .ico{font-size:30px;margin-bottom:14px;opacity:.35} /* ---------- 按钮 ---------- */ button{font-family:inherit;font-size:13px;cursor:pointer; border-radius:9px;padding:8px 15px;border:1px solid var(--border); background:#fff;color:var(--text);transition:.14s; font-weight:500} button:hover:not(:disabled){background:var(--surface); border-color:#d1d5db} button:active:not(:disabled){transform:translateY(1px)} button:disabled{opacity:.42;cursor:not-allowed} button.primary{background:var(--accent);border-color:var(--accent); color:#fff;font-weight:600} button.primary:hover:not(:disabled){background:#1d4ed8; border-color:#1d4ed8} button.danger{color:#b91c1c;border-color:#fecaca;background:#fff} button.danger:hover:not(:disabled){background:#fef2f2; border-color:#fca5a5} button.sm{padding:6px 12px;font-size:12px} /* ---------- 工具卡 ---------- */ .tools{display:grid;gap:14px;margin-top:30px} @media(min-width:720px){.tools{grid-template-columns:1fr 1fr}} .tool{border:1px solid var(--border);border-radius:13px;padding:18px 18px} .tool h4{font-size:13.5px;font-weight:650;margin-bottom:4px} .tool .d{color:var(--muted);font-size:12.5px;margin-bottom:14px} .tool .out{color:var(--muted);font-size:12.5px;line-height:1.85; word-break:break-all} .tool .out b{color:var(--fg);font-weight:600} .tool .out .good{color:#15803d;font-weight:600} .tool .out .bad{color:#b91c1c;font-weight:600} .dlrow{display:flex;align-items:center;gap:10px;padding:7px 0; border-bottom:1px solid var(--border-soft);font-size:12.5px} .dlrow:last-child{border-bottom:none} .dlrow .nm{flex:1;overflow:hidden;text-overflow:ellipsis; white-space:nowrap;font-family:ui-monospace,Consolas,monospace; font-size:11.5px} .dlrow .sz{color:var(--faint);font-variant-numeric:tabular-nums} /* ---------- 底部说明 ---------- */ .foot{margin-top:36px;padding-top:20px;border-top:1px solid var(--border); color:var(--faint);font-size:11.5px;line-height:1.9} .foot b{color:var(--muted);font-weight:600} /* ---------- 提示条 ---------- */ #toast{position:fixed;left:50%%;bottom:30px;transform:translateX(-50%%) translateY(20px);background:#1a1d21;color:#fff;padding:11px 20px; border-radius:10px;font-size:13px;opacity:0;pointer-events:none; transition:.22s;z-index:99;max-width:86vw;text-align:center} #toast.show{opacity:1;transform:translateX(-50%%) translateY(0)} #toast.ok{background:#15803d} #toast.err{background:#b91c1c} /* ---------- 窄屏:表格变卡片 ---------- */ @media(max-width:700px){ thead{display:none} tbody tr{display:block;border-bottom:1px solid var(--border); padding:12px 14px} tbody td{display:flex;justify-content:space-between;gap:14px; padding:5px 0;border:none} tbody td::before{content:attr(data-l);color:var(--faint);font-size:11.5px; flex:none} td.pickcell::before, td.ops::before{display:none} td.pickcell{padding-bottom:8px} td.ops{justify-content:flex-end;padding-top:10px; border-top:1px solid var(--border-soft)} td.empty{display:block;text-align:center} td.empty::before{display:none} .devid{max-width:200px} } """ % {} def _esc(value) -> str: """HTML 转义。""" text = "" if value is None else str(value) return (text.replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """)) def _uptime(seconds) -> str: try: total = int(seconds) except (TypeError, ValueError): return "—" if total < 60: return "%d 秒" % total if total < 3600: return "%d 分" % (total // 60) if total < 86400: return "%d 小时 %d 分" % (total // 3600, (total % 3600) // 60) return "%d 天 %d 小时" % (total // 86400, (total % 86400) // 3600) def _kb(value) -> str: try: n = float(value) except (TypeError, ValueError): return "0" if n < 1024: return "%d B" % n if n < 1024 * 1024: return "%.0f KB" % (n / 1024) return "%.1f MB" % (n / 1024 / 1024) def _device_rows(devices: list) -> str: if not devices: # 图标用内联 SVG 而不是 emoji —— # emoji 依赖系统字体,无头浏览器/精简系统里会渲染成方框。 return ('' '' '' '' '
还没有电脑连上来
' '

在教室那台电脑上双击 ykxmz.exe 设好 6 位配对码,' '手表上输这 6 位数字就能连上。

' '') out = [] for device in devices: online = device["online"] name = _esc(device["name"]) did = _esc(device["deviceId"]) code = _esc(device["code"]) out.append( '' '' '' '' '
' '%s
' '
%s
' '%s' '%s' '%ss' '%d / %d' '%s / %s' '' '' % ( did, name, "on" if online else "off", name, did, code, "on" if online else "off", "在线" if online else "离线", device["idleSeconds"], device["queuedToClient"], device["queuedToAgent"], _kb(device["bytesIn"]), _kb(device["bytesOut"]), did, name, code) ) return "".join(out) ADMIN_JS = """ const toast = (msg, kind) => { const el = document.getElementById('toast'); el.textContent = msg; el.className = kind ? ('show ' + kind) : 'show'; clearTimeout(el._t); el._t = setTimeout(() => { el.className = ''; }, 2800); }; const post = async (path, payload) => { const r = await fetch(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload || {}) }); return r.json(); }; // ---------------- 单台删除 ---------------- document.querySelectorAll('button.danger[data-device]').forEach(b => { b.onclick = async () => { const name = b.dataset.name, code = b.dataset.code; if (!confirm('删除设备「' + name + '」(配对码 ' + code + ')?\\n\\n' + '这会:\\n 1. 断开它现在的手表连接\\n' + ' 2. 从服务器删掉这台设备\\n' + ' 3. 清掉它的配对码绑定')) return; try { const j = await post('/v1/admin/delete', {deviceId: b.dataset.device}); if (j.ok) { toast('已删除「' + (j.name || name) + '」,清掉配对码 ' + (j.codesCleared || []).length + ' 条', 'ok'); setTimeout(() => location.reload(), 700); } else { toast('删除失败:' + (j.error || '未知错误'), 'err'); } } catch (e) { toast('请求出错:' + e, 'err'); } }; }); // ---------------- 批量选择 ---------------- const picks = () => Array.from(document.querySelectorAll('input.pick')); const picked = () => picks().filter(c => c.checked).map(c => c.value); function refreshPickState() { const chosen = picked().length; const btn = document.getElementById('delPicked'); if (btn) { btn.disabled = chosen === 0; btn.textContent = chosen ? ('删除选中(' + chosen + ')') : '删除选中'; } picks().forEach(c => { const tr = c.closest('tr'); if (tr) tr.classList.toggle('picked', c.checked); }); const all = document.getElementById('pickAll'); if (all) { const total = picks().length; all.checked = total > 0 && chosen === total; all.indeterminate = chosen > 0 && chosen < total; } } picks().forEach(c => c.addEventListener('change', refreshPickState)); const pickAll = document.getElementById('pickAll'); if (pickAll) pickAll.addEventListener('change', () => { picks().forEach(c => { c.checked = pickAll.checked; }); refreshPickState(); }); refreshPickState(); // ---------------- 批量删除 ---------------- const delPicked = document.getElementById('delPicked'); if (delPicked) delPicked.onclick = async () => { const ids = picked(); if (!ids.length) return; if (!confirm('删除选中的 ' + ids.length + ' 台设备?\\n\\n' + '会断开它们现在的手表连接,并清掉它们占的配对码。')) return; delPicked.disabled = true; try { const j = await post('/v1/admin/delete_many', {deviceIds: ids}); if (j.ok) { toast('已删除 ' + j.deleted + ' 台设备' + (j.failed ? (',' + j.failed + ' 台失败') : ''), 'ok'); setTimeout(() => location.reload(), 700); } else { toast('删除失败:' + (j.error || '未知错误'), 'err'); delPicked.disabled = false; } } catch (e) { toast('请求出错:' + e, 'err'); delPicked.disabled = false; } }; // ---------------- 删除全部 ---------------- const delAll = document.getElementById('delAll'); if (delAll) delAll.onclick = async () => { if (!confirm('删除全部设备(连同所有配对码)?\\n\\n' + '清场用,删完所有电脑都要重新设码。')) return; try { const j = await post('/v1/admin/delete_all'); toast('已删除 ' + (j.deleted || 0) + ' 台设备', 'ok'); setTimeout(() => location.reload(), 700); } catch (e) { toast('请求出错:' + e, 'err'); } }; // ---------------- 复制公网地址 ---------------- const copyUrl = document.getElementById('copyUrl'); if (copyUrl) copyUrl.onclick = async () => { const text = document.getElementById('urlBox').textContent.trim(); try { await navigator.clipboard.writeText(text); toast('地址已复制', 'ok'); } catch (e) { // 非安全上下文(http 且不是 localhost)没有 clipboard API, // 退回到老办法:选中 + execCommand const range = document.createRange(); range.selectNodeContents(document.getElementById('urlBox')); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); try { document.execCommand('copy'); toast('地址已复制', 'ok'); } catch (e2) { toast('复制失败,请手动选中', 'err'); } } }; // ---------------- 网络体检 ---------------- const netBtn = document.getElementById('netBtn'); if (netBtn) netBtn.onclick = async () => { const body = document.getElementById('netBody'); body.textContent = '正在测…'; try { const r = await fetch('/v1/admin/netcheck'); const j = await r.json(); if (!j.ok) { body.innerHTML = '读不到:' + (j.error || '未知') + ''; return; } const rows = []; if (j.tunnelMetrics) { const conns = j.tunnelConnections || 0; const cls = conns >= 2 ? 'good' : 'bad'; rows.push('隧道连接数:' + conns + '' + (conns === 0 ? '(断了,看门狗会自动重启)' : '')); rows.push('隧道累计请求:' + (j.tunnelRequests || 0) + ''); } else { rows.push('读不到隧道指标' + (j.tunnelError ? '(' + j.tunnelError + ')' : '') + ''); } rows.push('登记设备:' + j.devices + '(在线 ' + j.online + ')'); rows.push('手表会话:' + j.sessions + ''); rows.push('中转内存:' + Math.round(j.memoryKB / 1024) + ' MB'); rows.push('已运行:' + Math.round(j.uptimeSeconds / 60) + ' 分钟'); if (j.publicUrl) { rows.push('公网地址:' + j.publicUrl + ''); } body.innerHTML = rows.map(t => '
' + t + '
').join(''); // 顺便测浏览器到中转的往返 const t0 = performance.now(); try { await fetch('/v1/health?t=' + Date.now(), {cache: 'no-store'}); const ms = Math.round(performance.now() - t0); body.innerHTML += '
浏览器到中转往返:' + ms + ' ms' + (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)')) + '
'; } catch (e) { body.innerHTML += '
浏览器到中转:失败
'; } } catch (e) { body.textContent = '请求出错:' + e; } }; // ---------------- 自动刷新 ---------------- const autoRef = document.getElementById('autoRef'); let autoTimer = null; function setupAuto() { clearInterval(autoTimer); if (autoRef && autoRef.checked) { autoTimer = setInterval(() => { // 有人正在勾选/删除时别刷新,免得打断操作 if (picked().length === 0 && !document.hidden) location.reload(); }, 10000); } } if (autoRef) { autoRef.addEventListener('change', () => { try { localStorage.setItem('lr_autoref', autoRef.checked ? '1' : '0'); } catch (e) {} setupAuto(); }); try { if (localStorage.getItem('lr_autoref') === '0') autoRef.checked = false; } catch (e) {} setupAuto(); } """ def render_admin(snap: dict, public: str, admin_path: str, codes: int, downloads_fn) -> str: """渲染管理后台。 snap RELAY.snapshot() 的结果 public 公网地址(空串表示隧道没起来) admin_path 管理页路径(用于退出登录链接) codes 当前占用的配对码数 downloads_fn 取下载列表的函数(在 relay.py 里) """ devices = snap.get("devices", []) online_count = sum(1 for d in devices if d.get("online")) url_text = public or "" url_class = "url empty" if not url_text else "url" url_display = _esc(url_text) if url_text else "还没有地址 —— 隧道可能没起来" sessions = snap.get("sessions", 0) # 下载列表 try: files = downloads_fn() except Exception: # noqa: BLE001 files = [] if files: dl_rows = "".join( '
%s' '%s
' % (_esc(f["name"]), _kb(f["size"])) for f in files[:8]) else: dl_rows = ('
还没有上传任何安装包。' '把文件放到中转的 downloads 目录即可。
') return """ %(title)s
ykxmz 中转站 跨网络中转 · 管理后台
运行中
版本 %(ver)s · 已运行 %(up)s · 内存 %(mem)s MB
累计请求 %(req)s · 退出登录

公网地址 —— 手表「中转设置」里填这个

%(url)s
手表和电脑不在同一个网络时,两边都通过这个地址中转。
%(ndev)s
登记设备
%(non)s
在线
%(nses)s
手表会话
%(ncodes)s
配对码占用

设备

%(rows)s
设备配对码状态 空闲待发 表/机流量 入/出

网络体检

看看这台机器是走局域网还是走隧道。
还没测过。

下载区

官网下载按钮指向这里的文件。
%(dl)s
隐私说明 —— 数据来自 /v1/health,只显示配对码和流量数字, 看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。
""" % { "title": TITLE, "css": ADMIN_CSS, "js": ADMIN_JS, "ver": _esc(snap.get("version", "?")), "up": _uptime(snap.get("uptimeSeconds", 0)), "mem": int(snap.get("memoryKB", 0)) // 1024, "req": snap.get("totalRequests", 0), "admin": _esc(admin_path), "urlclass": url_class, "url": url_display, "dis": "" if url_text else " disabled", "ndev": len(devices), "non": online_count, "nses": sessions, "ncodes": codes, "oncls": "live" if online_count else "", "sescls": "busy" if sessions else "", "rows": _device_rows(devices), "dl": dl_rows, }