Files
ykxmz/_build/admin_preview.html
T
xmz7891 7b7830d8c5 阶段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,已同步到设备下载区
2026-10-07 00:13:10 +08:00

496 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>