隧道"假活"真因修复 + 中转管理后台重做

用户反馈两件事:
  1. 早上打开 relay.xxmxxs.top 看到 Cloudflare Error 1033 —— "这就是你说的优化好了?"
  2. "中转管理那边 ui 界面大改,现在难看的要死"

============ 一、隧道 1033/502/530 的真因 ============

之前我只测 15~25 次就说"可用率 100%",太草率 ——
实际是**间歇性假活**:隧道显示 ha_connections=2 连着,
公网却 502/530/000。watchdog 日志把过程记了下来。

根因:**4G 链路静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着)。
Cloudflare 把请求发到这条僵尸连接上就回错误。
Linux 默认 tcp_keepalive_time = **7200 秒(2 小时)**才发现,
中间这段时间服务一直"看着在线、实际打不开"。

修法(都有实测数据支撑):
  1. tcp_keepalive 30 秒 + 每 10 秒探测 3 次
     => 最多约 60 秒发现僵尸连接并重连
  2. 隧道连接数 2 -> 4
     (对照实验:http2+2 只有 1 条连上时公网 0/12;
       http2+4 稳定 12/12。连接数太少会出现"只有 1 条"的危险窗口)
  3. 协议定为 http2(cloudflared 自检说 TCP 7844 被墙,
     但实测 http2 反而比 quic 稳)

实测:http2+4 之后公网 40/40 = 100%,最慢 1.3 秒,0 次重连。

============ 二、中转管理后台重做 ============

原来是几百行内联在 relay.py 的 f-string 里(HTML+CSS+JS 全挤一起),
改一处样式要在一堆转义花括号里翻。拆成独立模块 relay/admin.py。

新版设计(对齐官网那套白色简洁):
  * 吸顶顶栏:品牌 + 运行状态 + 版本/内存/请求数
  * 公网地址给最大权重(这是用户最需要复制的东西)
  * 四个数字卡(在线/会话数带颜色区分)
  * 设备表卡片:状态圆点、等宽配对码、行悬停
  * 工具卡:网络体检 + 下载区并排
  * 空状态改成图标+引导文案,不再是干巴巴一句
  * 窄屏时表格自动变卡片

relay.py 从 2089 行缩到 1550 行。

============ 三、顺手修的两个真 bug ============

1. 设备记录只增不减
   self.devices 从不清理,被控端每次换端口/重装就多一条,
   实测一天攒了 75 条僵尸记录,/v1/health 从几 KB 涨到 20KB。
   加 DEVICE_TTL(6 小时)在 snapshot() 里清。

2. 配对码映射也没清
   RELAY.codes 和 devices 是一对孪生 dict,
   只清 devices 会留下指向已删设备的孤儿条目 ——
   管理页显示"配对码占用 131",实际一台设备都没有。
   现在一起清。

============ 四、我自己造的两次事故(如实记录) ============

1. 删文件删过头
   用"从 _admin_page 截到文件末尾"的方式替换方法,
   但 _admin_page 后面**还有 _esc/_kb/_uptime/class Server/main()**。
   结果 relay 起不来,systemd 重启 24 次,所有接口 000。
   教训:**"删到文件末尾"之前必须先确认末尾有什么。**

2. ADMIN_PATH 引用了不存在的常量
   路由里写的是字面量,我却在新代码里用了 ADMIN_PATH,
   管理页一访问就 NameError。现在统一定义了常量。

二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
This commit is contained in:
xmz7891
2026-10-07 09:34:53 +08:00
parent 8c4ee6df08
commit 1f05084f51
33 changed files with 3684 additions and 938 deletions
+640
View File
@@ -0,0 +1,640 @@
# -*- 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)}
.top-in{display:flex;align-items:center;gap:12px;height:62px}
.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 b{display:block;font-size:15px;font-weight:650;letter-spacing:-.2px}
.brand span{display:block;color:var(--faint);font-size:11px}
.state{margin-left:14px;display:inline-flex;align-items:center;gap:6px;
padding:3px 10px;border-radius:999px;background:#f0fdf4;
color:#15803d;font-size:11px;font-weight:600}
.state i{width:6px;height:6px;border-radius:50%%;background:#22c55e;
display:block}
.top-right{margin-left:auto;text-align:right;font-size:11px;
color:var(--faint);line-height:1.7}
.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)}
/* ---------- 公网地址(最重要,给最大权重)---------- */
.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("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
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 ('<tr><td colspan="8" class="empty">'
'<svg width="34" height="34" viewBox="0 0 24 24" fill="none" '
'stroke="currentColor" stroke-width="1.5" '
'stroke-linecap="round" stroke-linejoin="round" '
'style="opacity:.28;margin-bottom:14px">'
'<rect x="2" y="4" width="20" height="13" rx="2"/>'
'<path d="M8 21h8M12 17v4"/></svg>'
'<div class="big">还没有电脑连上来</div>'
'<p>在教室那台电脑上双击 ykxmz.exe 设好 6 位配对码,'
'手表上输这 6 位数字就能连上。</p>'
'</td></tr>')
out = []
for device in devices:
online = device["online"]
name = _esc(device["name"])
did = _esc(device["deviceId"])
code = _esc(device["code"])
out.append(
'<tr>'
'<td class="pickcell">'
'<input type="checkbox" class="pick" value="%s" '
'aria-label="选择 %s"></td>'
'<td data-l="设备">'
'<div class="devname">'
'<span class="dot %s"></span>%s</div>'
'<div class="devid">%s</div></td>'
'<td data-l="配对码"><code>%s</code></td>'
'<td data-l="状态"><span class="pill %s">%s</span></td>'
'<td data-l="空闲"><span class="mono">%ss</span></td>'
'<td data-l="待发 表/机"><span class="mono">%d / %d</span></td>'
'<td data-l="流量 入/出"><span class="mono">%s / %s</span></td>'
'<td class="ops"><button class="danger sm" '
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
'</tr>' % (
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 = '<span class="bad">读不到:'
+ (j.error || '未知') + '</span>';
return;
}
const rows = [];
if (j.tunnelMetrics) {
const conns = j.tunnelConnections || 0;
const cls = conns >= 2 ? 'good' : 'bad';
rows.push('隧道连接数:<b class="' + cls + '">' + conns + '</b>'
+ (conns === 0 ? '(断了,看门狗会自动重启)' : ''));
rows.push('隧道累计请求:<b>' + (j.tunnelRequests || 0) + '</b>');
} else {
rows.push('<span class="bad">读不到隧道指标'
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
}
rows.push('登记设备:<b>' + j.devices + '</b>(在线 <b>' + j.online
+ '</b>)');
rows.push('手表会话:<b>' + j.sessions + '</b>');
rows.push('中转内存:<b>' + Math.round(j.memoryKB / 1024) + ' MB</b>');
rows.push('已运行:<b>' + Math.round(j.uptimeSeconds / 60) + ' 分钟</b>');
if (j.publicUrl) {
rows.push('公网地址:<b>' + j.publicUrl + '</b>');
}
body.innerHTML = rows.map(t => '<div>' + t + '</div>').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 += '<div>浏览器到中转往返:<b class="'
+ (ms < 300 ? 'good' : 'bad') + '">' + ms + ' ms</b>'
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
+ '</div>';
} catch (e) {
body.innerHTML += '<div class="bad">浏览器到中转:失败</div>';
}
} 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(
'<div class="dlrow"><span class="nm">%s</span>'
'<span class="sz">%s</span></div>'
% (_esc(f["name"]), _kb(f["size"]))
for f in files[:8])
else:
dl_rows = ('<div class="out">还没有上传任何安装包。'
'把文件放到中转的 downloads 目录即可。</div>')
return """<!doctype html>
<html lang="zh-CN"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/favicon.ico">
<title>%(title)s</title>
<style>%(css)s</style>
</head><body>
<div class="top"><div class="wrap top-in">
<div class="logo">yx</div>
<div class="brand">
<b>ykxmz 中转站</b>
<span>跨网络中转 · 管理后台</span>
</div>
<span class="state"><i></i>运行中</span>
<div class="top-right">
<div>版本 <b>%(ver)s</b> · 已运行 <b>%(up)s</b> · 内存 <b>%(mem)s MB</b></div>
<div>累计请求 <b>%(req)s</b> · <a class="logout" href="%(admin)s/logout">退出登录</a></div>
</div>
</div></div>
<div class="wrap">
<div class="hero">
<h2>公网地址 —— 手表「中转设置」里填这个</h2>
<div class="urlbox">
<div class="%(urlclass)s" id="urlBox">%(url)s</div>
<button class="primary" id="copyUrl"%(dis)s>复制</button>
</div>
<div class="hint">手表和电脑不在同一个网络时,两边都通过这个地址中转。</div>
</div>
<div class="nums">
<div class="num"><div class="v">%(ndev)s</div><div class="k">登记设备</div></div>
<div class="num %(oncls)s"><div class="v">%(non)s</div><div class="k">在线</div></div>
<div class="num %(sescls)s"><div class="v">%(nses)s</div><div class="k">手表会话</div></div>
<div class="num"><div class="v">%(ncodes)s</div><div class="k">配对码占用</div></div>
</div>
<div class="sec">
<h3>设备</h3>
<div class="right">
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
<input type="checkbox" id="autoRef" checked> 自动刷新
</label>
<button class="danger sm" id="delPicked" disabled>删除选中</button>
<button class="danger sm" id="delAll">删除全部</button>
</div>
</div>
<div class="card">
<table>
<thead><tr>
<th style="width:38px"><input type="checkbox" id="pickAll" title="全选"></th>
<th>设备</th><th>配对码</th><th>状态</th>
<th>空闲</th><th>待发 表/机</th><th>流量 入/出</th><th></th>
</tr></thead>
<tbody>
%(rows)s
</tbody>
</table>
</div>
<div class="tools">
<div class="tool">
<h4>网络体检</h4>
<div class="d">看看这台机器是走局域网还是走隧道。</div>
<div class="out" id="netBody">还没测过。</div>
<div style="margin-top:14px"><button class="sm" id="netBtn">测一下</button></div>
</div>
<div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody">%(dl)s</div>
</div>
</div>
<div class="foot">
<b>隐私说明</b> —— 数据来自 /v1/health,只显示配对码和流量数字,
看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。
</div>
</div>
<div id="toast"></div>
<script>%(js)s</script>
</body></html>""" % {
"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,
}
+14
View File
@@ -20,6 +20,8 @@ from __future__ import annotations
import re
from urllib.parse import quote
import showcase
# ---------------- 设计令牌 ----------------
BG = "#ffffff"
SURFACE = "#fafbfc"
@@ -334,6 +336,13 @@ footer{padding:52px 0 64px;border-top:1px solid %(BORDER)s;
""" % {"BG": BG, "SURFACE": SURFACE, "BORDER": BORDER, "TEXT": TEXT,
"MUTED": MUTED, "FAINT": FAINT, "ACCENT": ACCENT}
# 产品展示那段的样式在 showcase.py 里,它自己已经格式化好了,
# 这里直接拼 —— **不能再套一层 %**。
# (踩过的坑:写成了 `""" + showcase.SHOWCASE_CSS % {...}`,
# % 优先级高于 +,于是把已经格式化过的 CSS 又格式化一遍,
# 报 "unsupported format character ';'" 直接把官网打挂。)
SITE_CSS = SITE_CSS + showcase.SHOWCASE_CSS
def render_site(files: list) -> str:
"""渲染官网首页。files 是 _list_downloads() 的结果。"""
@@ -359,6 +368,8 @@ def render_site(files: list) -> str:
</a>
<div class="nav-links">
<a href="#features">功能</a>
<a href="#shots">界面</a>
<a href="#how-connect">怎么连</a>
<a href="#how">怎么用</a>
<a href="#download">下载</a>
<a href="#faq">常见问题</a>
@@ -392,6 +403,8 @@ def render_site(files: list) -> str:
<div class="steps">%(steps)s</div>
</div></section>
%(showcase)s
<section id="download"><div class="wrap">
<div class="sec-head">
<h2>下载</h2>
@@ -432,6 +445,7 @@ document.querySelectorAll('a[href^="#"]').forEach(function (a) {
"badge": '<div class="badge">%s</div>' % badge if badge else "",
"feats": _feature_cards(),
"steps": _step_cards(),
"showcase": showcase.render_showcase(),
"dls": _download_cards(files),
"faq": _faq_items(),
"_dlsub": ("当前版本 " + version) if version
+64 -543
View File
@@ -186,6 +186,15 @@ def _list_downloads() -> list:
ADMIN_SESSIONS: dict = {}
ADMIN_SESSION_SECONDS = 12 * 3600
# 管理后台的路径。
# 用户要求官网放 /(公开),管理后台另开一个不那么好猜的路径。
#
# 之前这个常量其实**从来没定义过** —— 路由里写的是字面量
# "/zhongzhuanzhan",而新写的 _admin_page 引用了 ADMIN_PATH,
# 结果一访问管理页就 NameError(页面 000)。
# 现在统一定义,路由和页面都引用它,避免再次不一致。
ADMIN_PATH = "/zhongzhuanzhan"
def admin_password() -> str:
global ADMIN_PASSWORD
@@ -671,6 +680,16 @@ class Relay:
if s.device_id == did]:
self.sessions.pop(sid, None)
# 配对码映射也要一起清。
# 它和 devices 是一对孪生 dict,只清一个的话,
# codes 里会攒下一堆指向已删设备的孤儿条目 ——
# 实测管理页显示"配对码占用 131",实际一台设备都没有。
# 指向已不存在设备的码 = 废码,直接扔。
orphan = [code for code, owner in self.codes.items()
if owner not in self.devices]
for code in orphan:
self.codes.pop(code, None)
devices = []
for device in self.devices.values():
sessions = [s for s in self.sessions.values()
@@ -1071,6 +1090,30 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"ok": True, "files": _list_downloads()})
return
# 官网用的产品截图。
# 单独一条路由(不放进下载区),只允许 png/jpg/webp。
if path.startswith("/shot/"):
name = path[len("/shot/"):]
if (not name or ".." in name or "/" in name or "\\" in name
or not name.lower().endswith((".png", ".jpg", ".jpeg",
".webp"))):
self._json(404, {"ok": False, "error": "没有这张图"})
return
target = Path(__file__).resolve().parent / "site_shots" / name
if not target.is_file():
self._json(404, {"ok": False, "error": "没有这张图"})
return
body = target.read_bytes()
ctype = ("image/png" if name.lower().endswith(".png")
else "image/jpeg")
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "public, max-age=86400")
self.end_headers()
self.wfile.write(body)
return
if path.startswith("/download/"):
wanted = urllib.parse.unquote(path[len("/download/"):])
safe = _safe_download_name(wanted)
@@ -1218,10 +1261,10 @@ class Handler(BaseHTTPRequestHandler):
# 挪到 /zhongzhuanzhan(用户要求)。
# /admin 保留成跳转,免得存过书签的人打不开。
if path == "/admin":
self._redirect("/zhongzhuanzhan")
self._redirect(ADMIN_PATH)
return
if path == "/zhongzhuanzhan":
if path == ADMIN_PATH:
# 管理页要登录 —— 上面会显示配对码,不能谁都能看
if not self._admin_gate():
self._send(200, _login_page(
@@ -1340,12 +1383,12 @@ class Handler(BaseHTTPRequestHandler):
if not token:
log("管理页登录失败(%s),来自 %s"
% (username or "未填账号", self._client_ip()))
self._redirect("/zhongzhuanzhan?bad=1")
self._redirect(ADMIN_PATH + "?bad=1")
return
log("管理页登录成功(%s),来自 %s"
% (username, self._client_ip()))
self.send_response(302)
self.send_header("Location", "/zhongzhuanzhan")
self.send_header("Location", ADMIN_PATH)
self.send_header(
"Set-Cookie",
f"lr_admin={token}; Path=/; HttpOnly; SameSite=Lax; "
@@ -1462,552 +1505,32 @@ class Handler(BaseHTTPRequestHandler):
# ---------------- 管理页 ----------------
def _admin_page(self) -> None:
"""管理后台。
实际渲染在 relay/admin.py —— 原来几百行 HTML/CSS/JS
内联在这里的 f-string 里,改一处样式要在一堆转义花括号里翻,
没法维护。拆出去之后这里只负责把数据递过去。
"""
from admin import render_admin
snap = RELAY.snapshot()
rows = []
for device in snap["devices"]:
online = device["online"]
badge = ("<span class='badge on'>在线</span>" if online
else "<span class='badge off'>离线</span>")
rows.append(
"<tr>"
"<td class=pickcell>"
f'<input type=checkbox class=pick value="{_esc(device["deviceId"])}">'
"</td>"
# data-l 是给窄屏用的:表格变成卡片时,
# 每个值前面用 ::before 显示这个标签(见 CSS 的 @media)
f'<td class=name data-l="设备">'
f'<span class="dot {"on" if online else "off"}"></span>'
f'<span class=v>{_esc(device["name"])}</span>'
f'<div class=sub2>{_esc(device["deviceId"])}</div></td>'
f'<td data-l="配对码"><span class=val>'
f'<code>{_esc(device["code"])}</code></span></td>'
f'<td data-l="状态"><span class=val>{badge}</span></td>'
f'<td data-l="空闲"><span class=val>'
f'{device["idleSeconds"]}s</span></td>'
f'<td data-l="待发(表/机)"><span class=val>'
f'{device["queuedToClient"]} / '
f'{device["queuedToAgent"]}</span></td>'
f'<td class=num data-l="流量(入/出)"><span class=val>'
f'{_kb(device["bytesIn"])} / {_kb(device["bytesOut"])}'
f'</span></td>'
'<td class=ops><button class="btn del" '
f'data-device="{_esc(device["deviceId"])}" '
f'data-name="{_esc(device["name"])}" '
f'data-code="{_esc(device["code"])}">删除</button></td>'
"</tr>")
self._send(200, render_admin(
snap=snap,
public=public_url() or "",
admin_path=ADMIN_PATH,
codes=len(RELAY.codes),
downloads_fn=_list_downloads,
).encode("utf-8"))
online_count = sum(1 for d in snap["devices"] if d["online"])
url_text = public_url() or ""
url_display = _esc(url_text) if url_text else "(还没有地址,隧道可能没起来)"
empty = ('<tr><td colspan=8 class=empty>'
'<div class=big>还没有电脑连上来</div>'
'<div>在被控端双击 ykxmz.exe 设好配对码,'
'手表上输那 6 位数字就能连上。</div></td></tr>')
html = f"""<!doctype html><html lang=zh>
<meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<link rel="icon" href="/favicon.ico">
<title>ykxmz 中转服务器</title>
<style>
*{{box-sizing:border-box}}
:root{{
--bg:#ffffff; --panel:#ffffff; --panel2:#f6f8fa; --border:#d0d7de;
--fg:#1f2328; --dim:#656d76; --muted:#8b949e;
--accent:#0969da; --ok:#1a7f37; --danger:#f85149; --warn:#9a6700;
}}
html,body{{max-width:100%;overflow-x:hidden}}
body{{margin:0;padding:0;background:var(--bg);color:var(--fg);
font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',
'Microsoft YaHei',sans-serif;-webkit-font-smoothing:antialiased}}
.wrap{{width:100%;max-width:1120px;margin:0 auto;padding:22px 20px 40px}}
/* ---------- 头部 ---------- */
header{{display:flex;align-items:center;gap:12px;
padding-bottom:16px;border-bottom:1px solid var(--border);
margin-bottom:20px;flex-wrap:wrap}}
.logo{{width:38px;height:38px;border-radius:10px;flex:none;
background:linear-gradient(140deg,#0969da,#1f6feb);
display:flex;align-items:center;justify-content:center;
font-weight:700;font-size:15px;color:#fff;letter-spacing:-.5px}}
.brand h1{{margin:0;font-size:17px;font-weight:600;letter-spacing:.2px}}
.brand .sub{{color:var(--dim);font-size:12px}}
.meta{{margin-left:auto;text-align:right;color:var(--muted);font-size:12px;
line-height:1.5}}
.meta b{{color:var(--dim);font-weight:500}}
.meta a.logout{{color:var(--dim);text-decoration:none;
border-bottom:1px dashed #d0d7de;transition:.12s}}
.meta a.logout:hover{{color:var(--accent);border-bottom-color:var(--accent)}}
/* ---------- 通用卡片 ---------- */
.panel{{background:var(--panel);border:1px solid var(--border);
border-radius:12px;padding:16px 18px;margin-bottom:16px}}
.panel .label{{color:var(--dim);font-size:12px;margin-bottom:8px;
display:flex;align-items:center;gap:6px}}
/* ---------- 公网地址 ---------- */
.urlrow{{display:flex;align-items:center;gap:10px;flex-wrap:wrap}}
.url{{flex:1;min-width:220px;font:600 15px/1.5 ui-monospace,Consolas,
'Courier New',monospace;color:#0969da;word-break:break-all;
background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;
padding:10px 12px}}
.url.empty{{color:var(--muted);font-weight:400;font-style:normal}}
/* ---------- 统计 ---------- */
.stats{{display:grid;gap:12px;margin-bottom:16px;
grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}}
.stat{{background:var(--panel);border:1px solid var(--border);
border-radius:12px;padding:14px 16px;position:relative;overflow:hidden}}
.stat .n{{font-size:26px;font-weight:650;line-height:1.15;
letter-spacing:-.5px}}
.stat .t{{color:var(--dim);font-size:12px;margin-top:2px}}
.stat::after{{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;
background:var(--accent);opacity:.65}}
.stat.ok::after{{background:var(--ok)}}
.stat.warn::after{{background:var(--warn)}}
.stat.mute::after{{background:#d0d7de}}
/* ---------- 工具条 ---------- */
.toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;
margin-bottom:12px}}
.toolbar .spacer{{flex:1}}
.tip{{color:var(--muted);font-size:12px}}
/* ---------- 按钮 ---------- */
button{{font-family:inherit;font-size:13px;cursor:pointer;
border-radius:8px;padding:7px 14px;border:1px solid var(--border);
background:var(--panel2);color:var(--fg);transition:.12s}}
button:hover{{background:#f3f4f6;border-color:#d0d7de}}
button:active{{transform:translateY(1px)}}
button:disabled{{opacity:.4;cursor:not-allowed;transform:none}}
button.primary{{background:var(--accent);border-color:var(--accent);
color:#fff;font-weight:600}}
button.primary:hover{{background:#1d4ed8;border-color:#1d4ed8}}
button.del{{background:#fff5f5;border-color:#ffc9c9;color:#c92a2a}}
button.del:hover{{background:#ffe3e3;border-color:#a33;color:#a61b1b}}
button.del:disabled{{opacity:.4}}
button.small{{padding:5px 11px;font-size:12px}}
/* ---------- 表格 ---------- */
.tablewrap{{background:var(--panel);border:1px solid var(--border);
border-radius:12px;overflow:hidden;max-width:100%}}
table{{width:100%;max-width:100%;border-collapse:collapse;
table-layout:fixed}}
th,td{{padding:11px 14px;text-align:left;font-size:13px;
border-bottom:1px solid #eaeef2;vertical-align:middle;
overflow-wrap:anywhere;min-width:0}}
th{{background:var(--panel2);color:var(--dim);font-weight:500;
font-size:12px;white-space:nowrap}}
tbody tr{{transition:background .1s}}
tbody tr:hover td{{background:#f6f8fa}}
tbody tr:last-child td{{border-bottom:none}}
td.name{{font-weight:500}}
td.num{{font-family:ui-monospace,Consolas,monospace;color:var(--dim);
font-size:12px}}
td.ops{{text-align:right;white-space:nowrap}}
.sub2{{color:var(--muted);font-size:11px;font-weight:400;
font-family:ui-monospace,Consolas,monospace;margin-top:1px}}
.val{{min-width:0;overflow-wrap:anywhere;word-break:break-word}}
td.empty{{text-align:center;padding:44px 20px;color:var(--dim)}}
td.empty .big{{font-size:15px;color:var(--fg);margin-bottom:6px;font-weight:500}}
code{{background:#eaeef2;padding:2px 8px;border-radius:6px;
letter-spacing:1.5px;font-family:ui-monospace,Consolas,monospace;
font-size:13px;color:#0969da}}
.dot{{display:inline-block;width:7px;height:7px;border-radius:50%;
margin-right:7px;vertical-align:middle;background:#d0d7de}}
.dot.on{{background:var(--ok);box-shadow:0 0 0 3px #1a7f3722}}
.badge{{font-size:11px;padding:2px 8px;border-radius:20px;
border:1px solid transparent;white-space:nowrap}}
.badge.on{{color:var(--ok);background:#1a7f371a;border-color:#1a7f3740}}
.badge.off{{color:var(--muted);background:#8b949e1a;border-color:#8b949e40}}
.pickcell{{width:38px;text-align:center;padding-right:0}}
input[type=checkbox]{{width:15px;height:15px;cursor:pointer;
accent-color:var(--accent);vertical-align:middle}}
tr.picked td{{background:#eff6ff}}
tr.picked:hover td{{background:#dbeafe}}
/* ---------- 提示条 ---------- */
#toast{{position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);
background:#1f6feb;color:#fff;padding:11px 20px;border-radius:10px;
font-size:13px;box-shadow:0 8px 24px #0002;opacity:0;
transition:.22s;pointer-events:none;max-width:88vw;text-align:center}}
#toast.show{{opacity:1;transform:translate(-50%,0)}}
#toast.err{{background:#b62324}}
#toast.ok{{background:#1a7f37}}
footer{{color:var(--muted);font-size:12px;margin-top:22px;
padding-top:14px;border-top:1px solid var(--border);line-height:1.7}}
/* ---------- 窄屏:表格变卡片 ---------- */
@media (max-width:720px){{
.wrap{{padding:16px 12px 32px}}
/* 从根上保证不会有东西超出容器。
表格在窄屏变成卡片式,这几条必须写死,
不然 table-layout:fixed 对 display:block 的行不起作用。 */
table,tbody,tr,td{{display:block;width:100%;max-width:100%;
box-sizing:border-box}}
.meta{{margin-left:0;text-align:left;width:100%}}
.stats{{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}}
.stat{{padding:11px 13px}}
.stat .n{{font-size:21px}}
.urlrow{{flex-direction:column;align-items:stretch}}
.url{{min-width:0;font-size:13px}}
thead{{display:none}}
tbody tr{{display:block;border-bottom:1px solid var(--border);
padding:12px 14px 14px;position:relative}}
tbody tr:last-child{{border-bottom:none}}
/* 每个值一行:左边标签、右边值。
min-width:0 + overflow-wrap 是关键 ——
少了它,长配对码/流量会把容器撑破、右边的值被截掉。 */
tbody td{{display:flex;justify-content:space-between;align-items:baseline;
gap:14px;border:none;padding:3px 0;text-align:right;
min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word}}
tbody td .val{{flex:1 1 auto;min-width:0;text-align:right}}
td.name{{display:block}}
tbody td::before{{content:attr(data-l);color:var(--dim);font-size:12px;
text-align:left;flex:none}}
tbody td>*{{min-width:0}}
/* 设备名占满整行,不参与"标签/值"那一套 */
td.name{{display:block;text-align:left;padding:0 32px 8px 0}}
td.name::before{{content:'';display:none}}
td.name .v{{font-size:15px}}
td.pickcell{{position:absolute;right:12px;top:12px;width:auto;
padding:0;min-width:0}}
td.pickcell::before{{content:'';display:none}}
td.ops{{justify-content:flex-end;padding-top:10px;margin-top:4px;
border-top:1px solid #eaeef2}}
td.ops::before{{content:'';display:none}}
td.empty{{display:block;text-align:center}}
td.empty::before{{content:'';display:none}}
}}
</style>
<div class=wrap>
<header>
<div class=logo>yx</div>
<div class=brand>
<h1>ykxmz 中转服务器</h1>
<div class=sub>远程控制 · 跨网络中转</div>
</div>
<div class=meta>
<div>版本 <b>{_esc(snap['version'])}</b> · 已运行 <b>{_uptime(snap['uptimeSeconds'])}</b></div>
<div>本进程内存 <b>{snap['memoryKB'] // 1024} MB</b> · 累计请求 <b>{snap['totalRequests']}</b>
· <a href="/admin/logout" class=logout>退出登录</a></div>
</div>
</header>
<div class=panel>
<div class=label>当前公网地址 —— 手表「中转设置」里填这个</div>
<div class=urlrow>
<div class="url{' empty' if not url_text else ''}" id=urlBox>{url_display}</div>
<button class="btn primary" id=copyUrl {'disabled' if not url_text else ''}>复制</button>
</div>
</div>
<div class=panel id=netPanel>
<div class=label>网络体检 —— 走的是局域网还是隧道?</div>
<div id=netBody class=tip>点下面按钮测一下</div>
<div style="margin-top:10px">
<button class="btn small" id=netBtn>测一下</button>
</div>
</div>
<div class=panel id=dlPanel>
<div class=label>下载最新版</div>
<div id=dlBody class=tip>正在读…</div>
</div>
<div class=stats>
<div class=stat><div class=n>{len(snap['devices'])}</div><div class=t>登记设备</div></div>
<div class="stat {'ok' if online_count else 'mute'}"><div class=n>{online_count}</div><div class=t>在线</div></div>
<div class="stat {'warn' if snap['sessions'] else 'mute'}"><div class=n>{snap['sessions']}</div><div class=t>手表会话</div></div>
<div class=stat><div class=n>{len(RELAY.codes)}</div><div class=t>配对码占用</div></div>
</div>
<div class=toolbar>
<button class="btn del" id=delPicked>删除选中</button>
<button class="btn del" id=delAll>删除全部设备</button>
<div class=spacer></div>
<label class=tip style="cursor:pointer;display:flex;align-items:center;gap:6px">
<input type=checkbox id=autoRef checked> 自动刷新(10 秒)
</label>
</div>
<div class=tip style="margin:-4px 0 12px">
删除会连配对码一起清掉,那台设备下次可以重新设码。
</div>
<div class=tablewrap>
<table>
<thead><tr>
<th class=pickcell><input type=checkbox id=pickAll title="全选"></th>
<th>设备</th><th>配对码</th><th>状态</th>
<th>空闲</th><th>待发(表/机)</th><th>流量(入/出)</th><th></th>
</tr></thead>
<tbody>
{''.join(rows) or empty}
</tbody>
</table>
</div>
<footer>
数据来自 /v1/health · 只显示配对码与流量,看不到任何传输内容<br>
中转只是转发字节,不解析、不存储会话内容。
</footer>
</div>
<div id=toast></div>
<script>
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 = ''; }}, 2600);
}};
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.del[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 = '<span style="color:#f85149">读不到:'
+ (j.error || '未知') + '</span>';
return;
}}
const rows = [];
if (j.tunnelMetrics) {{
const conns = j.tunnelConnections || 0;
const color = conns >= 2 ? '#1a7f37'
: (conns === 1 ? '#9a6700' : '#f85149');
rows.push('隧道连接数:<b style="color:' + color + '">' + conns
+ '</b>' + (conns === 0 ? '(断了,看门狗会重启)' : ''));
rows.push('隧道累计请求:' + (j.tunnelRequests || 0));
}} else {{
rows.push('<span style="color:#9a6700">读不到隧道指标'
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
}}
rows.push('登记设备:' + j.devices + '(在线 ' + j.online + ')');
rows.push('手表会话:' + j.sessions);
rows.push('中转内存:' + Math.round(j.memoryKB / 1024) + ' MB');
rows.push('已运行:' + j.uptimeSeconds + ' 秒');
if (j.publicUrl) {{
rows.push('公网地址:<code style="letter-spacing:0">'
+ j.publicUrl + '</code>');
}}
body.innerHTML = rows.map(t => '<div style="margin:2px 0">' + t
+ '</div>').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 += '<div style="margin:2px 0">浏览器到中转往返:<b>'
+ ms + ' ms</b>'
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
+ '</div>';
}} catch (e) {{
body.innerHTML += '<div style="margin:2px 0;color:#f85149">'
+ '浏览器到中转:失败(' + e + ')</div>';
}}
}} catch (e) {{
body.textContent = '请求出错:' + e;
}}
}};
// ---------------- 下载(就一个链接)----------------
(async () => {{
const body = document.getElementById('dlBody');
if (!body) return;
try {{
const r = await fetch('/v1/downloads');
const j = await r.json();
const files = (j && j.files) || [];
if (!files.length) {{
body.innerHTML = '<span style="color:#656d76">服务器上还没有成品。</span>';
return;
}}
// 优先挑被控端整包;没有就挑第一个 zip;再没有就第一个
const pick = files.find(f => /agent/i.test(f.name))
|| files.find(f => /\.zip$/i.test(f.name))
|| files[0];
const mb = (pick.size / 1024 / 1024).toFixed(2);
body.innerHTML =
'<a href="/download/' + encodeURIComponent(pick.name) + '" '
+ 'style="display:inline-block;padding:10px 20px;border-radius:6px;'
+ 'background:#238636;color:#fff;text-decoration:none;'
+ 'font-weight:600;font-size:15px">'
+ '⬇ 下载 ' + pick.name + '</a>'
+ '<div style="color:#8b949e;font-size:12px;margin-top:8px">'
+ mb + ' MB · 也可以去 '
+ '<a href="https://gitee.com/xmz7891/ykxmz/releases" target="_blank" '
+ 'rel="noopener" style="color:#0969da">Gitee 历史版本</a>'
+ '</div>';
}} catch (e) {{
body.innerHTML = '<span style="color:#f85149">读不到:' + e + '</span>';
}}
}})();
// ---------------- 自动刷新 ----------------
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 {{
const saved = localStorage.getItem('lr_autoref');
if (saved === '0') autoRef.checked = false;
}} catch (e) {{}}
setupAuto();
}}
</script>
</html>"""
self._send(200, html.encode("utf-8"), "text/html; charset=utf-8")
# ======================================================================
# 下面这几个是页面/启动辅助(重做管理页时被误删,从 git 恢复)
# ======================================================================
def _esc(text) -> str:
return (str(text).replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
def _kb(value: int) -> str:
if value < 1024:
return f"{value} B"
@@ -2015,7 +1538,6 @@ def _kb(value: int) -> str:
return f"{value / 1024:.1f} KB"
return f"{value / 1024 / 1024:.1f} MB"
def _uptime(seconds: int) -> str:
days, rest = divmod(int(seconds), 86400)
hours, rest = divmod(rest, 3600)
@@ -2036,7 +1558,6 @@ class Server(ThreadingHTTPServer):
allow_reuse_address = True
request_queue_size = 64
def main() -> int:
DATA_DIR.mkdir(parents=True, exist_ok=True)
try:
+214
View File
@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
r'''官网的"产品展示"部分:手表界面 mockup + 连接方式示意。
为什么用 HTML/CSS 画手表界面,而不是放截图:
* 手表是安卓设备,没有真机/模拟器就拍不到界面
* CSS 画的图在任何屏幕上都是清晰的(截图放大会糊)
* 用的颜色和布局**直接从 MainActivity.java 抄**,
所以不是"想象的样子",是真实界面
手表真实参数(android/.../MainActivity.java):
C_BG = 0xFFF5F7FA 页面底色(浅灰)
C_FG = 0xFF1F2328 主文字(近黑)
C_ACCENT = 0xFF185CE8 强调蓝
C_DIM = 0xFF656D76 次要文字
十个分页:媒体 屏幕 文件 语音 电脑 网络 系统 教室 效率 娱乐
'''
from __future__ import annotations
# 和手表代码一致的颜色
W_BG = "#F5F7FA"
W_FG = "#1F2328"
W_ACCENT = "#185CE8"
W_DIM = "#656D76"
W_CARD = "#FFFFFF"
W_BORDER = "#E3E7ED"
TABS = ["媒体", "屏幕", "文件", "语音", "电脑",
"网络", "系统", "教室", "效率", "娱乐"]
# "媒体"分页的真实操作(从 TabDef 抄的)
MEDIA_ACTIONS = [
("放音频", "🔊"), ("放视频", "▶"), ("暂停", "⏸"),
("继续", "⏵"), ("音量 +", "+"), ("音量 −", "−"),
("静音", "🔇"), ("下一首", "⏭"), ("投图到屏幕", "🖼"),
]
# "教室"分页(老师的核心场景)
CLASSROOM_ACTIONS = [
("上课:开投影", "📽"), ("打开课件", "📂"), ("课堂专注", "🎯"),
("恢复桌面", "↩"), ("看屏幕", "👁"), ("下课关机", "⏻"),
]
TAB_ACTIONS = {"媒体": MEDIA_ACTIONS, "教室": CLASSROOM_ACTIONS}
def _watch_card(active_tab: str) -> str:
"""画一个手表界面。active_tab 决定显示哪个分页的内容。"""
actions = TAB_ACTIONS.get(active_tab, MEDIA_ACTIONS)
# 顶部标签条
tabs = []
for name in TABS:
on = name == active_tab
tabs.append(
'<span class="w-tab%s">%s</span>' % (" on" if on else "", name))
tabs_html = "".join(tabs)
# 操作网格
buttons = []
for label, icon in actions:
buttons.append(
'<div class="w-btn"><span class="w-ic">%s</span>'
'<span class="w-lb">%s</span></div>' % (icon, label))
grid_html = "".join(buttons)
return (
'<div class="watch">'
'<div class="w-screen">'
'<div class="w-status"><span>ykxmz</span>'
'<span class="w-conn">● 局域网 3 ms</span></div>'
'<div class="w-tabs">%s</div>'
'<div class="w-body">'
'<div class="w-head">%s</div>'
'<div class="w-grid">%s</div>'
'</div>'
'<div class="w-foot">被控端:教室电脑 · 192.168.2.64</div>'
'</div></div>' % (tabs_html, active_tab, grid_html)
)
def _pc_card() -> str:
"""电脑端 —— 用真实截图(_build/site_shots/pc-main.png)。"""
return (
'<figure class="pcshot">'
'<img src="/shot/pc-main.png" alt="ykxmz 电脑端首次设置界面" '
'loading="lazy">'
'<figcaption>电脑端:装完填个 6 位配对码就行</figcaption>'
'</figure>'
)
def _flow_diagram() -> str:
"""连接方式示意:局域网直连 vs 跨网络走中转。"""
return '''
<div class="flow">
<div class="flow-col">
<div class="flow-tag ok">同一个 WiFi</div>
<div class="flow-row">
<div class="node">⌚<span>手表</span></div>
<div class="link"><span class="ms">3 ms</span>
<div class="line"></div></div>
<div class="node">🖥<span>教室电脑</span></div>
</div>
<p>直连,不经过任何服务器。几十毫秒,画面基本跟手。</p>
</div>
<div class="flow-col">
<div class="flow-tag warn">不在同一个网络</div>
<div class="flow-row">
<div class="node">⌚<span>手表</span></div>
<div class="link"><div class="line"></div></div>
<div class="node relay">☁<span>中转</span></div>
<div class="link"><div class="line"></div></div>
<div class="node">🖥<span>电脑</span></div>
</div>
<p>两边都主动连到中转,绕过 NAT。办公室也能管教室。</p>
</div>
</div>
'''
def render_showcase() -> str:
"""整个"产品展示"区块。"""
return '''
<section id="shots"><div class="wrap">
<div class="sec-head">
<h2>长什么样</h2>
<p>手表上十个分页,常用操作都在第一屏</p>
</div>
<div class="showcase">
%(watch)s
%(pc)s
</div>
</div></section>
<section id="how-connect"><div class="wrap">
<div class="sec-head">
<h2>怎么连上的</h2>
<p>同一个 WiFi 就走局域网直连,不同网络自动切中转</p>
</div>
%(flow)s
</div></section>
''' % {"watch": _watch_card("媒体"), "pc": _pc_card(),
"flow": _flow_diagram()}
SHOWCASE_CSS = """
/* ---------- 产品展示 ---------- */
.showcase{display:grid;gap:34px;align-items:start;
justify-items:center}
@media(min-width:820px){.showcase{grid-template-columns:1fr 1fr}}
/* 手表 mockup。尺寸按真实手表屏幕比例 */
.watch{width:300px;padding:10px;border-radius:34px;
background:linear-gradient(160deg,#2b3038,#14171c);
box-shadow:0 18px 40px rgba(16,24,40,.18);
position:relative}
.watch::after{content:'';position:absolute;right:-3px;top:110px;
width:5px;height:44px;border-radius:3px;background:#3a4048}
.w-screen{background:%(W_BG)s;border-radius:26px;overflow:hidden;
font-size:11px;color:%(W_FG)s}
.w-status{display:flex;justify-content:space-between;align-items:center;
padding:11px 12px 7px;font-size:9px;font-weight:600;
gap:6px;white-space:nowrap}
.w-conn{color:#16a34a;font-weight:600}
.w-tabs{display:flex;gap:4px;overflow:hidden;padding:0 11px 8px;
border-bottom:1px solid %(W_BORDER)s}
.w-tab{flex:none;padding:3px 8px;border-radius:999px;color:%(W_DIM)s;
font-size:10px;white-space:nowrap}
.w-tab.on{background:%(W_ACCENT)s;color:#fff;font-weight:600}
.w-body{padding:12px 13px 14px}
.w-head{font-size:12px;font-weight:700;margin-bottom:10px}
.w-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}
.w-btn{background:%(W_CARD)s;border:1px solid %(W_BORDER)s;
border-radius:11px;padding:11px 4px 9px;text-align:center;
display:flex;flex-direction:column;align-items:center;gap:5px}
.w-ic{font-size:15px;line-height:1}
.w-lb{font-size:9px;color:%(W_FG)s;white-space:nowrap}
.w-foot{padding:9px 13px 12px;color:%(W_DIM)s;font-size:9px;
border-top:1px solid %(W_BORDER)s}
/* 电脑端截图 */
.pcshot{margin:0;text-align:center}
.pcshot img{width:100%%;max-width:352px;height:auto;
border-radius:10px;display:block;margin:0 auto;
box-shadow:0 18px 40px rgba(16,24,40,.16)}
.pcshot{padding:14px;background:#f2f4f7;border-radius:16px;
border:1px solid %(W_BORDER)s}
.pcshot figcaption{margin-top:14px;color:%(W_DIM)s;font-size:13px}
/* 连接方式示意 */
.flow{display:grid;gap:18px}
@media(min-width:760px){.flow{grid-template-columns:1fr 1fr}}
.flow-col{border:1px solid #e6e8eb;border-radius:14px;padding:24px 20px;
background:#fafbfc}
.flow-tag{display:inline-block;padding:3px 11px;border-radius:999px;
font-size:12px;font-weight:600;margin-bottom:20px}
.flow-tag.ok{background:#dcfce7;color:#15803d}
.flow-tag.warn{background:#fef3c7;color:#a16207}
.flow-row{display:flex;align-items:center;justify-content:center;
gap:2px;margin-bottom:16px;flex-wrap:nowrap}
.node{display:flex;flex-direction:column;align-items:center;gap:6px;
font-size:26px;flex:none;width:64px}
.node span{font-size:12px;color:#4b5563;font-weight:500}
.node.relay{font-size:24px}
.link{flex:1;min-width:16px;position:relative;display:flex;
align-items:center;justify-content:center}
.link .line{width:100%%;height:2px;background:#c7cdd6;
border-radius:2px}
.link .ms{position:absolute;top:-15px;font-size:10px;color:#15803d;
font-weight:600;white-space:nowrap}
.flow-col p{color:#6b7280;font-size:13px;line-height:1.7;margin:0}
""" % {"W_BG": W_BG, "W_FG": W_FG, "W_ACCENT": W_ACCENT, "W_DIM": W_DIM,
"W_CARD": W_CARD, "W_BORDER": W_BORDER}