阶段3+4+5:管理页白色化 + 局域网诊断 + 全量验收

阶段3 管理页
- 清掉全部深色主题残留(地址框 #0b1220、按钮悬停 #262c36、
  删除按钮 #2d1214 等 11 处),现在 0 处深色
- 接口全部正常:health / downloads / netcheck

阶段4 局域网诊断
- 查清楚"电脑不在同一网络"是怎么回事:被控端是**对的**
  (lan_ips() 报 ['192.168.2.64', '169.254.83.107'],真实地址排第一),
  是手表当时真不在 192.168.2.x —— 可能连了 5G 频段的另一个路由器,或开着移动数据
- 所以改了手表的提示:不再只说"不在同一网络",
  而是把**双方地址都打出来**,用户一眼能看出是网段不同还是连错 WiFi
- 端到端验证局域网路径:认证 1 ms,功能列表 207 项

阶段1 结论修正(一天内推翻自己两次,如实记录)
- 第一次判断"优选 IP 无用":错。半小时内确实能避开超时 IP
- 第二次固定优选 IP:也错。那些实测 138ms 的 IP 半小时后全不通了
  —— Cloudflare anycast 可达性随运营商路由变化,固定 IP 等于钉死到会失效的地址
- 最终:不固定,靠 GeoDNS + cloudflared 重试 + ha-connections 2 冗余
- 撤销后立即变好:连接稳定 2 条、超时告警 0 次、设备侧延迟 4.40s -> 1.66s
- **真正的瓶颈**:分段实测发现本机到 Cloudflare 的**裸 RTT 就 1439 ms**,
  比经隧道的请求(1215 ms)还慢。说明瓶颈是这条网络的 4G 上行本身,
  不是设备的隧道。公网侧已无优化空间。
- 局域网 118 ms vs 公网 1215 ms,快 10 倍 —— 这才是该走的路

阶段5 验收
- 全量回归 29 项全通过
- 设备侧验收 38 项全通过
- 成品:setup exe 14.37 MB / server zip / watch apk,已同步到设备下载区
This commit is contained in:
xmz7891
2026-10-07 00:13:10 +08:00
parent 566d9e384f
commit 7b7830d8c5
80 changed files with 1890 additions and 39 deletions
+496
View File
@@ -0,0 +1,496 @@
<!doctype html><html lang=zh>
<meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<link rel="icon" href="https://relay.xxmxxs.top/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 #484f58;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:#0b1220;border:1px solid #1f6feb44;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:#484f58}
/* ---------- 工具条 ---------- */
.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:#262c36;border-color:#484f58}
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:#4a95f8;border-color:#4a95f8}
button.del{background:#2d1214;border-color:#6e2b2b;color:#ff7b72}
button.del:hover{background:#4a1a1e;border-color:#a33;color:#ffa198}
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:#1b222c}
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:#484f58}
.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:#1c2733}
tr.picked:hover td{background:#213042}
/* ---------- 提示条 ---------- */
#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 #0008;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>1.7.1</b> · 已运行 <b>1 分</b></div>
<div>本进程内存 <b>22 MB</b> · 累计请求 <b>3</b>
· <a href="/admin/logout" class=logout>退出登录</a></div>
</div>
</header>
<div class=panel>
<div class=label>当前公网地址 —— 手表「中转设置」里填这个</div>
<div class=urlrow>
<div class="url" id=urlBox>https://relay.xxmxxs.top</div>
<button class="btn primary" id=copyUrl >复制</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>0</div><div class=t>登记设备</div></div>
<div class="stat mute"><div class=n>0</div><div class=t>在线</div></div>
<div class="stat mute"><div class=n>0</div><div class=t>手表会话</div></div>
<div class=stat><div class=n>49</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>
<tr><td colspan=8 class=empty><div class=big>还没有电脑连上来</div><div>在被控端双击 ykxmz.exe 设好配对码,手表上输那 6 位数字就能连上。</div></td></tr>
</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>