Files
ykxmz/_build/redesign_admin_page.py
T
xmz7891 b705216e4a ykxmz 1.5.0锛氫慨澶嶆墜琛ㄧ TLS 璇佷功鏍¢獙 + MTU 淇缃戠粶绋冲畾鎬?
琚帶绔?- 淇 MTU 闂锛氬す MSS + 璺緞 MTU 鎺㈡祴锛堝叕缃戝彲鐢ㄧ巼 48% -> 100%锛?- 澶嶇敤 HTTP 杩炴帴锛堝厤鎺夋瘡娆℃柊寤?TLS 鐨?30% 鎻℃墜澶辫触锛?- 姣忓垎閽熷洖澶存壘灞€鍩熺綉锛岃嚜鍔ㄥ垏鍥炲揩 400 鍊嶇殑璺緞
- 鐢婚潰蹇冭烦甯э細闈欐鏃剁渷娴侀噺锛屽鎴风浠嶇煡閬撴祦鏄椿鐨?- 閿佸睆鏃剁粰鍑哄彲鎿嶄綔鐨勬彁绀?
鎵嬭〃绔?- 鍐呯疆 GTS Root R4 / GlobalSign Root CA锛屼慨 SSLHandshakeException
- 杩炴帴閿欒缈昏瘧鎴愪汉璇濓紙璇佷功/鏃堕棿/瑙f瀽/瓒呮椂鍒嗗埆鎻愮ず锛?- 缁忎腑杞繛涓婂悗鐢ㄦ湇鍔″櫒鎶ョ殑鍦板潃鍒锋柊宸查厤瀵硅褰?- 鏂板灞€鍩熺綉杩炴帴椤甸潰銆佹湇鍔″櫒寤惰繜妫€娴?
鏈嶅姟鍣ㄧ
- 鏀寔 chunked 璇锋眰浣擄紙淇瘡 16 绉掍竴娆$殑杩炴帴閿欎贡涓庡ぇ闈㈢Н 502锛?- 缃戠粶璋冧紭鑴氭湰 net-tune.sh锛堝紑鏈鸿嚜鍔ㄥ簲鐢級
- 绠$悊椤碉細鎵归噺鍒犻櫎銆佺綉缁滀綋妫€銆佷笅杞藉尯
- 闅ч亾鏀?HTTP/2 + 鍙岃繛鎺?
鏂囨。
- 璁稿彲鍗忚 / 瀹夎鍓嶅繀璇?/ 瀹夎鍚庨』鐭ワ紙浣滆€咃細灏忛害瀛愶級
2026-10-06 19:01:05 +08:00

474 lines
19 KiB
Python
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.
# -*- coding: utf-8 -*-
"""重做中转管理页的界面。
功能一个不少:
公网地址展示 + 一键复制、统计卡片、设备表格、
单台删除、批量删除、删除全部、登录保护
界面上的改进:
* 头部:品牌 + 状态灯 + 版本/运行时长
* 公网地址做成醒目的大卡片,带「复制」按钮
* 统计卡片换成更清楚的样式
* 表格:在线/离线用徽章,配对码用等宽 chip,行悬停高亮
* 空状态给一句人话,而不是一行灰字
* 加「自动刷新」开关(默认 10 秒一次),省得手动 F5
* 窄屏(手机)表格自动变成卡片式,不用左右拖
* 操作结果用页面内提示条,不再打断式 alert(删除确认仍用 confirm)
⚠️ 两个必须注意的坑:
1. 这段是 f-string,CSS/JS 里的花括号都要写两遍 {{{{ }}}}
2. JS 字符串里的换行要写 \\\\n(源码里两反斜杠),
否则 f-string 会把它变成真换行,整段脚本报错(幽灵按键)
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
RELAY = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay" r"\relay.py")
NEW_PAGE = ''' def _admin_page(self) -> None:
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>"
f'<td class=name>'
f'<span class="dot {"on" if online else "off"}"></span>'
f'{_esc(device["name"])}<div class=sub2>'
f'{_esc(device["deviceId"])}</div></td>'
f'<td><code>{_esc(device["code"])}</code></td>'
f'<td>{badge}</td>'
f'<td>{device["idleSeconds"]}s</td>'
f'<td>{device["queuedToClient"]} / {device["queuedToAgent"]}</td>'
f'<td class=num>{_kb(device["bytesIn"])} / {_kb(device["bytesOut"])}</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>")
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">
<title>ykxmz 中转服务器</title>
<style>
*{{box-sizing:border-box}}
:root{{
--bg:#0d1117; --panel:#161b22; --panel2:#1c2128; --border:#30363d;
--fg:#e6edf3; --dim:#8b949e; --muted:#6e7681;
--accent:#2f81f7; --ok:#3fb950; --danger:#f85149; --warn:#d29922;
}}
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{{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,#2f81f7,#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}}
/* ---------- 通用卡片 ---------- */
.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:#79c0ff;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}}
table{{width:100%;border-collapse:collapse}}
th,td{{padding:11px 14px;text-align:left;font-size:13px;
border-bottom:1px solid #21262d;vertical-align:middle}}
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}}
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:#21262d;padding:2px 8px;border-radius:6px;
letter-spacing:1.5px;font-family:ui-monospace,Consolas,monospace;
font-size:13px;color:#79c0ff}}
.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 #3fb95022}}
.badge{{font-size:11px;padding:2px 8px;border-radius:20px;
border:1px solid transparent;white-space:nowrap}}
.badge.on{{color:var(--ok);background:#3fb9501a;border-color:#3fb95040}}
.badge.off{{color:var(--muted);background:#6e76811a;border-color:#6e768140}}
.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}}
.meta{{margin-left:0;text-align:left;width:100%}}
thead{{display:none}}
tbody tr{{display:block;border-bottom:1px solid var(--border);
padding:12px 14px;position:relative}}
tbody tr:last-child{{border-bottom:none}}
tbody td{{display:flex;justify-content:space-between;gap:12px;
border:none;padding:4px 0;text-align:right}}
tbody td::before{{content:attr(data-l);color:var(--dim);font-size:12px;
text-align:left;flex:none}}
td.pickcell{{position:absolute;right:12px;top:12px;width:auto;
padding:0}}
td.pickcell::before{{content:''}}
td.name{{display:block;text-align:left;padding-right:30px}}
td.name::before{{content:''}}
td.ops{{justify-content:flex-end;padding-top:8px}}
td.ops::before{{content:''}}
td.empty{{display:block}}
td.empty::before{{content:''}}
}}
</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></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=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 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")
'''
def main() -> int:
text = RELAY.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
start = None
end = None
for index, line in enumerate(lines):
if line.startswith(" def _admin_page(self) -> None:"):
start = index
elif start is not None and index > start and (
line.startswith(" def ")
or line.startswith("def ")
or line.startswith("class ")):
end = index
break
if start is None or end is None:
print(" [失败] 找不到 _admin_page 的边界")
return 1
new_text = "".join(lines[:start]) + NEW_PAGE + "\n" + "".join(lines[end:])
try:
ast.parse(new_text)
except SyntaxError as exc:
print(f" [失败] 生成的代码语法错误: 行 {exc.lineno}: {exc.msg}")
return 1
RELAY.write_text(new_text, encoding="utf-8", newline="\n")
print(f" 已重写 _admin_page({end - start} 行 -> "
f"{NEW_PAGE.count(chr(10))} 行)")
return 0
if __name__ == "__main__":
sys.exit(main())