mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 14:50:45 +00:00
用户反馈两件事:
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。现在统一定义了常量。
二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
463 lines
20 KiB
HTML
463 lines
20 KiB
HTML
<!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>ykxmz 中转站</title>
|
||
<style>
|
||
*{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}
|
||
}
|
||
</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>1.7.1</b> · 已运行 <b>9 小时 10 分</b> · 内存 <b>24 MB</b></div>
|
||
<div>累计请求 <b>1919</b> · <a class="logout" href="/zhongzhuanzhan/logout">退出登录</a></div>
|
||
</div>
|
||
</div></div>
|
||
|
||
<div class="wrap">
|
||
|
||
<div class="hero">
|
||
<h2>公网地址 —— 手表「中转设置」里填这个</h2>
|
||
<div class="urlbox">
|
||
<div class="url" id="urlBox">https://relay.xxmxxs.top</div>
|
||
<button class="primary" id="copyUrl">复制</button>
|
||
</div>
|
||
<div class="hint">手表和电脑不在同一个网络时,两边都通过这个地址中转。</div>
|
||
</div>
|
||
|
||
<div class="nums">
|
||
<div class="num"><div class="v">2</div><div class="k">登记设备</div></div>
|
||
<div class="num live"><div class="v">1</div><div class="k">在线</div></div>
|
||
<div class="num "><div class="v">0</div><div class="k">手表会话</div></div>
|
||
<div class="num"><div class="v">2</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>
|
||
<tr><td class="pickcell"><input type="checkbox" class="pick" value="DESKTOP-ABC-123" aria-label="选择 教室电脑"></td><td data-l="设备"><div class="devname"><span class="dot on"></span>教室电脑</div><div class="devid">DESKTOP-ABC-123</div></td><td data-l="配对码"><code>246810</code></td><td data-l="状态"><span class="pill on">在线</span></td><td data-l="空闲"><span class="mono">3s</span></td><td data-l="待发 表/机"><span class="mono">0 / 0</span></td><td data-l="流量 入/出"><span class="mono">12 KB / 7 KB</span></td><td class="ops"><button class="danger sm" data-device="DESKTOP-ABC-123" data-name="教室电脑" data-code="246810">删除</button></td></tr><tr><td class="pickcell"><input type="checkbox" class="pick" value="DESKTOP-ABC-456" aria-label="选择 办公室电脑"></td><td data-l="设备"><div class="devname"><span class="dot off"></span>办公室电脑</div><div class="devid">DESKTOP-ABC-456</div></td><td data-l="配对码"><code>135790</code></td><td data-l="状态"><span class="pill off">离线</span></td><td data-l="空闲"><span class="mono">900s</span></td><td data-l="待发 表/机"><span class="mono">0 / 0</span></td><td data-l="流量 入/出"><span class="mono">0 B / 0 B</span></td><td class="ops"><button class="danger sm" data-device="DESKTOP-ABC-456" data-name="办公室电脑" data-code="135790">删除</button></td></tr>
|
||
</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"><div class="dlrow"><span class="nm">ykxmz-setup-1.7.1.exe</span><span class="sz">14.4 MB</span></div><div class="dlrow"><span class="nm">ykxmz-watch-1.7.1.apk</span><span class="sz">197 KB</span></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="foot">
|
||
<b>隐私说明</b> —— 数据来自 /v1/health,只显示配对码和流量数字,
|
||
看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。
|
||
</div>
|
||
</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 = ''; }, 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();
|
||
}
|
||
</script>
|
||
</body></html> |