mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 12:30:44 +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。现在统一定义了常量。
二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
def _esc(text) -> str:
|
||||||
|
return (str(text).replace("&", "&").replace("<", "<")
|
||||||
|
.replace(">", ">").replace('"', """))
|
||||||
|
|
||||||
|
|
||||||
|
def _kb(value: int) -> str:
|
||||||
|
if value < 1024:
|
||||||
|
return f"{value} B"
|
||||||
|
if value < 1024 * 1024:
|
||||||
|
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)
|
||||||
|
minutes = rest // 60
|
||||||
|
if days:
|
||||||
|
return f"{days} 天 {hours} 小时"
|
||||||
|
if hours:
|
||||||
|
return f"{hours} 小时 {minutes} 分"
|
||||||
|
return f"{minutes} 分"
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# 启动
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
class Server(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
request_queue_size = 64
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
server = Server((BIND, PORT), Handler)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"监听 {BIND}:{PORT} 失败:{exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
log(f"中转服务器 v{VERSION} 启动,监听 {BIND}:{PORT}")
|
||||||
|
log(f"长轮询 {POLL_SECONDS:.0f} 秒 · 队列上限 "
|
||||||
|
f"{MAX_QUEUE_BYTES // 1024} KB · 数据目录 {DATA_DIR}")
|
||||||
|
key = admin_key()
|
||||||
|
log(f"管理页:http://<本机>:{PORT}/?key={key}")
|
||||||
|
log(f"(钥匙也在 {DATA_DIR / 'admin_key.txt'})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("收到中断,退出")
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"port": 8898,
|
||||||
|
"server_name": "DESKTOP-RN5I625",
|
||||||
|
"allow_run": true,
|
||||||
|
"allow_power": true,
|
||||||
|
"allow_screen": true,
|
||||||
|
"start_minimized": false,
|
||||||
|
"allow_new_pairing": true,
|
||||||
|
"image_timeout": 0,
|
||||||
|
"image_display": "window",
|
||||||
|
"image_custom_width": 0,
|
||||||
|
"image_custom_height": 0,
|
||||||
|
"image_window_title": "",
|
||||||
|
"tts_voice": "",
|
||||||
|
"tts_rate": 0,
|
||||||
|
"mp3_dir": "",
|
||||||
|
"file_roots": "",
|
||||||
|
"extra_apps": "",
|
||||||
|
"extra_commands": "",
|
||||||
|
"download_dir": "received",
|
||||||
|
"screen_max_width": 480,
|
||||||
|
"screen_quality": 72,
|
||||||
|
"screen_fps": 12,
|
||||||
|
"pairing_code": "242524",
|
||||||
|
"pairing_created": 1791335816.802805
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"port": 8898,
|
||||||
|
"server_name": "DESKTOP-RN5I625",
|
||||||
|
"allow_run": true,
|
||||||
|
"allow_power": true,
|
||||||
|
"allow_screen": true,
|
||||||
|
"start_minimized": false,
|
||||||
|
"allow_new_pairing": true,
|
||||||
|
"image_timeout": 0,
|
||||||
|
"image_display": "window",
|
||||||
|
"image_custom_width": 0,
|
||||||
|
"image_custom_height": 0,
|
||||||
|
"image_window_title": "",
|
||||||
|
"tts_voice": "",
|
||||||
|
"tts_rate": 0,
|
||||||
|
"mp3_dir": "",
|
||||||
|
"file_roots": "",
|
||||||
|
"extra_apps": "",
|
||||||
|
"extra_commands": "",
|
||||||
|
"download_dir": "received",
|
||||||
|
"screen_max_width": 480,
|
||||||
|
"screen_quality": 72,
|
||||||
|
"screen_fps": 12,
|
||||||
|
"pairing_code": "598318",
|
||||||
|
"pairing_created": 1791335863.3186042
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
<!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="https://relay.xxmxxs.top/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>20 秒</b> · 内存 <b>22 MB</b></div>
|
||||||
|
<div>累计请求 <b>20</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">0</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">0</div><div class="k">手表会话</div></div>
|
||||||
|
<div class="num"><div class="v">131</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 colspan="8" class="empty"><div class="ico">🖥</div><div class="big">还没有电脑连上来</div><p>在教室那台电脑上双击 ykxmz.exe 设好 6 位配对码,手表上输这 6 位数字就能连上。</p></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-watch-1.7.1.apk</span><span class="sz">197 KB</span></div><div class="dlrow"><span class="nm">ykxmz-server-1.7.1.zip</span><span class="sz">198 KB</span></div><div class="dlrow"><span class="nm">ykxmz-setup-1.7.1.exe</span><span class="sz">14.4 MB</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>
|
||||||
+263
-296
@@ -1,263 +1,267 @@
|
|||||||
<!doctype html><html lang=zh>
|
<!doctype html>
|
||||||
<meta charset=utf-8>
|
<html lang="zh-CN"><head>
|
||||||
<meta name=viewport content="width=device-width,initial-scale=1">
|
<meta charset="utf-8">
|
||||||
<link rel="icon" href="https://relay.xxmxxs.top/favicon.ico">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>ykxmz 中转服务器</title>
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<title>ykxmz 中转站</title>
|
||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{
|
:root{
|
||||||
--bg:#ffffff; --panel:#ffffff; --panel2:#f6f8fa; --border:#d0d7de;
|
--bg:#ffffff; --surface:#fafbfc; --border:#e6e8eb; --border-soft:#f0f2f5;
|
||||||
--fg:#1f2328; --dim:#656d76; --muted:#8b949e;
|
--fg:#1a1d21; --muted:#6b7280; --faint:#9ca3af;
|
||||||
--accent:#0969da; --ok:#1a7f37; --danger:#f85149; --warn:#9a6700;
|
--accent:#2563eb; --ok:#16a34a; --warn:#d97706; --danger:#dc2626;
|
||||||
}
|
}
|
||||||
html,body{max-width:100%;overflow-x:hidden}
|
html,body{max-width:100%;overflow-x:hidden}
|
||||||
body{margin:0;padding:0;background:var(--bg);color:var(--fg);
|
body{background:var(--bg);color:var(--fg);line-height:1.6;
|
||||||
font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',
|
||||||
'Microsoft YaHei',sans-serif;-webkit-font-smoothing:antialiased}
|
'PingFang SC',sans-serif;-webkit-font-smoothing:antialiased}
|
||||||
.wrap{width:100%;max-width:1120px;margin:0 auto;padding:22px 20px 40px}
|
a{color:inherit}
|
||||||
|
.wrap{width:100%;max-width:1040px;margin:0 auto;padding:0 20px 64px}
|
||||||
|
|
||||||
/* ---------- 头部 ---------- */
|
/* ---------- 吸顶顶栏 ---------- */
|
||||||
header{display:flex;align-items:center;gap:12px;
|
.top{position:sticky;top:0;z-index:40;background:rgba(255,255,255,.88);
|
||||||
padding-bottom:16px;border-bottom:1px solid var(--border);
|
backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--border)}
|
||||||
margin-bottom:20px;flex-wrap:wrap}
|
.top-in{display:flex;align-items:center;gap:12px;height:62px}
|
||||||
.logo{width:38px;height:38px;border-radius:10px;flex:none;
|
.logo{width:32px;height:32px;border-radius:9px;flex:none;
|
||||||
background:linear-gradient(140deg,#0969da,#1f6feb);
|
background:linear-gradient(140deg,#2563eb,#1d4ed8);
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
font-weight:700;font-size:15px;color:#fff;letter-spacing:-.5px}
|
color:#fff;font-weight:700;font-size:12px;letter-spacing:-.3px}
|
||||||
.brand h1{margin:0;font-size:17px;font-weight:600;letter-spacing:.2px}
|
.brand b{display:block;font-size:15px;font-weight:650;letter-spacing:-.2px}
|
||||||
.brand .sub{color:var(--dim);font-size:12px}
|
.brand span{display:block;color:var(--faint);font-size:11px}
|
||||||
.meta{margin-left:auto;text-align:right;color:var(--muted);font-size:12px;
|
.state{margin-left:14px;display:inline-flex;align-items:center;gap:6px;
|
||||||
line-height:1.5}
|
padding:3px 10px;border-radius:999px;background:#f0fdf4;
|
||||||
.meta b{color:var(--dim);font-weight:500}
|
color:#15803d;font-size:11px;font-weight:600}
|
||||||
.meta a.logout{color:var(--dim);text-decoration:none;
|
.state i{width:6px;height:6px;border-radius:50%;background:#22c55e;
|
||||||
border-bottom:1px dashed #484f58;transition:.12s}
|
display:block}
|
||||||
.meta a.logout:hover{color:var(--accent);border-bottom-color:var(--accent)}
|
.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)}
|
||||||
|
|
||||||
/* ---------- 通用卡片 ---------- */
|
/* ---------- 公网地址(最重要,给最大权重)---------- */
|
||||||
.panel{background:var(--panel);border:1px solid var(--border);
|
.hero{padding:30px 0 26px}
|
||||||
border-radius:12px;padding:16px 18px;margin-bottom:16px}
|
.hero h2{font-size:13px;font-weight:600;color:var(--muted);
|
||||||
.panel .label{color:var(--dim);font-size:12px;margin-bottom:8px;
|
margin-bottom:10px;letter-spacing:.2px}
|
||||||
display:flex;align-items:center;gap:6px}
|
.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}
|
||||||
|
|
||||||
/* ---------- 公网地址 ---------- */
|
/* ---------- 数字卡 ---------- */
|
||||||
.urlrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
.nums{display:grid;gap:10px;grid-template-columns:repeat(2,1fr);
|
||||||
.url{flex:1;min-width:220px;font:600 15px/1.5 ui-monospace,Consolas,
|
margin-bottom:30px}
|
||||||
'Courier New',monospace;color:#0969da;word-break:break-all;
|
@media(min-width:640px){.nums{grid-template-columns:repeat(4,1fr)}}
|
||||||
background:#0b1220;border:1px solid #1f6feb44;border-radius:8px;
|
.num{background:var(--surface);border:1px solid var(--border);
|
||||||
padding:10px 12px}
|
border-radius:12px;padding:15px 16px}
|
||||||
.url.empty{color:var(--muted);font-weight:400;font-style:normal}
|
.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}
|
||||||
|
|
||||||
/* ---------- 统计 ---------- */
|
/* ---------- 区块标题 ---------- */
|
||||||
.stats{display:grid;gap:12px;margin-bottom:16px;
|
.sec{display:flex;align-items:center;gap:12px;margin-bottom:12px}
|
||||||
grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}
|
.sec h3{font-size:15px;font-weight:650;letter-spacing:-.2px}
|
||||||
.stat{background:var(--panel);border:1px solid var(--border);
|
.sec .right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||||
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;
|
.card{background:#fff;border:1px solid var(--border);border-radius:13px;
|
||||||
margin-bottom:12px}
|
overflow:hidden}
|
||||||
.toolbar .spacer{flex:1}
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
.tip{color:var(--muted);font-size:12px}
|
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;
|
button{font-family:inherit;font-size:13px;cursor:pointer;
|
||||||
border-radius:8px;padding:7px 14px;border:1px solid var(--border);
|
border-radius:9px;padding:8px 15px;border:1px solid var(--border);
|
||||||
background:var(--panel2);color:var(--fg);transition:.12s}
|
background:#fff;color:var(--text);transition:.14s;
|
||||||
button:hover{background:#262c36;border-color:#484f58}
|
font-weight:500}
|
||||||
button:active{transform:translateY(1px)}
|
button:hover:not(:disabled){background:var(--surface);
|
||||||
button:disabled{opacity:.4;cursor:not-allowed;transform:none}
|
border-color:#d1d5db}
|
||||||
button.primary{background:var(--accent);border-color:var(--accent);
|
button:active:not(:disabled){transform:translateY(1px)}
|
||||||
color:#fff;font-weight:600}
|
button:disabled{opacity:.42;cursor:not-allowed}
|
||||||
button.primary:hover{background:#4a95f8;border-color:#4a95f8}
|
button.primary{background:var(--accent);border-color:var(--accent);
|
||||||
button.del{background:#2d1214;border-color:#6e2b2b;color:#ff7b72}
|
color:#fff;font-weight:600}
|
||||||
button.del:hover{background:#4a1a1e;border-color:#a33;color:#ffa198}
|
button.primary:hover:not(:disabled){background:#1d4ed8;
|
||||||
button.del:disabled{opacity:.4}
|
border-color:#1d4ed8}
|
||||||
button.small{padding:5px 11px;font-size:12px}
|
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}
|
||||||
|
|
||||||
/* ---------- 表格 ---------- */
|
/* ---------- 工具卡 ---------- */
|
||||||
.tablewrap{background:var(--panel);border:1px solid var(--border);
|
.tools{display:grid;gap:14px;margin-top:30px}
|
||||||
border-radius:12px;overflow:hidden;max-width:100%}
|
@media(min-width:720px){.tools{grid-template-columns:1fr 1fr}}
|
||||||
table{width:100%;max-width:100%;border-collapse:collapse;
|
.tool{border:1px solid var(--border);border-radius:13px;padding:18px 18px}
|
||||||
table-layout:fixed}
|
.tool h4{font-size:13.5px;font-weight:650;margin-bottom:4px}
|
||||||
th,td{padding:11px 14px;text-align:left;font-size:13px;
|
.tool .d{color:var(--muted);font-size:12.5px;margin-bottom:14px}
|
||||||
border-bottom:1px solid #eaeef2;vertical-align:middle;
|
.tool .out{color:var(--muted);font-size:12.5px;line-height:1.85;
|
||||||
overflow-wrap:anywhere;min-width:0}
|
word-break:break-all}
|
||||||
th{background:var(--panel2);color:var(--dim);font-weight:500;
|
.tool .out b{color:var(--fg);font-weight:600}
|
||||||
font-size:12px;white-space:nowrap}
|
.tool .out .good{color:#15803d;font-weight:600}
|
||||||
tbody tr{transition:background .1s}
|
.tool .out .bad{color:#b91c1c;font-weight:600}
|
||||||
tbody tr:hover td{background:#1b222c}
|
.dlrow{display:flex;align-items:center;gap:10px;padding:7px 0;
|
||||||
tbody tr:last-child td{border-bottom:none}
|
border-bottom:1px solid var(--border-soft);font-size:12.5px}
|
||||||
td.name{font-weight:500}
|
.dlrow:last-child{border-bottom:none}
|
||||||
td.num{font-family:ui-monospace,Consolas,monospace;color:var(--dim);
|
.dlrow .nm{flex:1;overflow:hidden;text-overflow:ellipsis;
|
||||||
font-size:12px}
|
white-space:nowrap;font-family:ui-monospace,Consolas,monospace;
|
||||||
td.ops{text-align:right;white-space:nowrap}
|
font-size:11.5px}
|
||||||
.sub2{color:var(--muted);font-size:11px;font-weight:400;
|
.dlrow .sz{color:var(--faint);font-variant-numeric:tabular-nums}
|
||||||
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);
|
.foot{margin-top:36px;padding-top:20px;border-top:1px solid var(--border);
|
||||||
background:#1f6feb;color:#fff;padding:11px 20px;border-radius:10px;
|
color:var(--faint);font-size:11.5px;line-height:1.9}
|
||||||
font-size:13px;box-shadow:0 8px 24px #0008;opacity:0;
|
.foot b{color:var(--muted);font-weight:600}
|
||||||
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}
|
#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:720px){
|
@media(max-width:700px){
|
||||||
.wrap{padding:16px 12px 32px}
|
thead{display:none}
|
||||||
/* 从根上保证不会有东西超出容器。
|
tbody tr{display:block;border-bottom:1px solid var(--border);
|
||||||
表格在窄屏变成卡片式,这几条必须写死,
|
padding:12px 14px}
|
||||||
不然 table-layout:fixed 对 display:block 的行不起作用。 */
|
tbody td{display:flex;justify-content:space-between;gap:14px;
|
||||||
table,tbody,tr,td{display:block;width:100%;max-width:100%;
|
padding:5px 0;border:none}
|
||||||
box-sizing:border-box}
|
tbody td::before{content:attr(data-l);color:var(--faint);font-size:11.5px;
|
||||||
.meta{margin-left:0;text-align:left;width:100%}
|
flex:none}
|
||||||
.stats{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
td.pickcell::before,
|
||||||
.stat{padding:11px 13px}
|
td.ops::before{display:none}
|
||||||
.stat .n{font-size:21px}
|
td.pickcell{padding-bottom:8px}
|
||||||
.urlrow{flex-direction:column;align-items:stretch}
|
td.ops{justify-content:flex-end;padding-top:10px;
|
||||||
.url{min-width:0;font-size:13px}
|
border-top:1px solid var(--border-soft)}
|
||||||
thead{display:none}
|
td.empty{display:block;text-align:center}
|
||||||
tbody tr{display:block;border-bottom:1px solid var(--border);
|
td.empty::before{display:none}
|
||||||
padding:12px 14px 14px;position:relative}
|
.devid{max-width:200px}
|
||||||
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>
|
</style>
|
||||||
|
</head><body>
|
||||||
|
|
||||||
<div class=wrap>
|
<div class="top"><div class="wrap top-in">
|
||||||
<header>
|
<div class="logo">yx</div>
|
||||||
<div class=logo>yx</div>
|
<div class="brand">
|
||||||
<div class=brand>
|
<b>ykxmz 中转站</b>
|
||||||
<h1>ykxmz 中转服务器</h1>
|
<span>跨网络中转 · 管理后台</span>
|
||||||
<div class=sub>远程控制 · 跨网络中转</div>
|
</div>
|
||||||
</div>
|
<span class="state"><i></i>运行中</span>
|
||||||
<div class=meta>
|
<div class="top-right">
|
||||||
<div>版本 <b>1.7.1</b> · 已运行 <b>1 分</b></div>
|
<div>版本 <b>1.7.1</b> · 已运行 <b>9 小时 10 分</b> · 内存 <b>24 MB</b></div>
|
||||||
<div>本进程内存 <b>22 MB</b> · 累计请求 <b>3</b>
|
<div>累计请求 <b>1919</b> · <a class="logout" href="/zhongzhuanzhan/logout">退出登录</a></div>
|
||||||
· <a href="/admin/logout" class=logout>退出登录</a></div>
|
</div>
|
||||||
</div>
|
</div></div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class=panel>
|
<div class="wrap">
|
||||||
<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="hero">
|
||||||
<div class=label>网络体检 —— 走的是局域网还是隧道?</div>
|
<h2>公网地址 —— 手表「中转设置」里填这个</h2>
|
||||||
<div id=netBody class=tip>点下面按钮测一下</div>
|
<div class="urlbox">
|
||||||
<div style="margin-top:10px">
|
<div class="url" id="urlBox">https://relay.xxmxxs.top</div>
|
||||||
<button class="btn small" id=netBtn>测一下</button>
|
<button class="primary" id="copyUrl">复制</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="hint">手表和电脑不在同一个网络时,两边都通过这个地址中转。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class=panel id=dlPanel>
|
<div class="nums">
|
||||||
<div class=label>下载最新版</div>
|
<div class="num"><div class="v">2</div><div class="k">登记设备</div></div>
|
||||||
<div id=dlBody class=tip>正在读…</div>
|
<div class="num live"><div class="v">1</div><div class="k">在线</div></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=stats>
|
<div class="sec">
|
||||||
<div class=stat><div class=n>0</div><div class=t>登记设备</div></div>
|
<h3>设备</h3>
|
||||||
<div class="stat mute"><div class=n>0</div><div class=t>在线</div></div>
|
<div class="right">
|
||||||
<div class="stat mute"><div class=n>0</div><div class=t>手表会话</div></div>
|
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
|
||||||
<div class=stat><div class=n>49</div><div class=t>配对码占用</div></div>
|
<input type="checkbox" id="autoRef" checked> 自动刷新
|
||||||
</div>
|
</label>
|
||||||
|
<button class="danger sm" id="delPicked" disabled>删除选中</button>
|
||||||
|
<button class="danger sm" id="delAll">删除全部</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class=toolbar>
|
<div class="card">
|
||||||
<button class="btn del" id=delPicked>删除选中</button>
|
<table>
|
||||||
<button class="btn del" id=delAll>删除全部设备</button>
|
<thead><tr>
|
||||||
<div class=spacer></div>
|
<th style="width:38px"><input type="checkbox" id="pickAll" title="全选"></th>
|
||||||
<label class=tip style="cursor:pointer;display:flex;align-items:center;gap:6px">
|
<th>设备</th><th>配对码</th><th>状态</th>
|
||||||
<input type=checkbox id=autoRef checked> 自动刷新(10 秒)
|
<th>空闲</th><th>待发 表/机</th><th>流量 入/出</th><th></th>
|
||||||
</label>
|
</tr></thead>
|
||||||
</div>
|
<tbody>
|
||||||
<div class=tip style="margin:-4px 0 12px">
|
<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>
|
||||||
</div>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class=tablewrap>
|
<div class="tools">
|
||||||
<table>
|
<div class="tool">
|
||||||
<thead><tr>
|
<h4>网络体检</h4>
|
||||||
<th class=pickcell><input type=checkbox id=pickAll title="全选"></th>
|
<div class="d">看看这台机器是走局域网还是走隧道。</div>
|
||||||
<th>设备</th><th>配对码</th><th>状态</th>
|
<div class="out" id="netBody">还没测过。</div>
|
||||||
<th>空闲</th><th>待发(表/机)</th><th>流量(入/出)</th><th></th>
|
<div style="margin-top:14px"><button class="sm" id="netBtn">测一下</button></div>
|
||||||
</tr></thead>
|
</div>
|
||||||
<tbody>
|
<div class="tool">
|
||||||
<tr><td colspan=8 class=empty><div class=big>还没有电脑连上来</div><div>在被控端双击 ykxmz.exe 设好配对码,手表上输那 6 位数字就能连上。</div></td></tr>
|
<h4>下载区</h4>
|
||||||
</tbody>
|
<div class="d">官网下载按钮指向这里的文件。</div>
|
||||||
</table>
|
<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>
|
||||||
|
|
||||||
<footer>
|
<div class="foot">
|
||||||
数据来自 /v1/health · 只显示配对码与流量,看不到任何传输内容<br>
|
<b>隐私说明</b> —— 数据来自 /v1/health,只显示配对码和流量数字,
|
||||||
中转只是转发字节,不解析、不存储会话内容。
|
看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。
|
||||||
</footer>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id=toast></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const toast = (msg, kind) => {
|
const toast = (msg, kind) => {
|
||||||
const el = document.getElementById('toast');
|
const el = document.getElementById('toast');
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
el.className = kind ? ('show ' + kind) : 'show';
|
el.className = kind ? ('show ' + kind) : 'show';
|
||||||
clearTimeout(el._t);
|
clearTimeout(el._t);
|
||||||
el._t = setTimeout(() => { el.className = ''; }, 2600);
|
el._t = setTimeout(() => { el.className = ''; }, 2800);
|
||||||
};
|
};
|
||||||
|
|
||||||
const post = async (path, payload) => {
|
const post = async (path, payload) => {
|
||||||
@@ -270,7 +274,7 @@ const post = async (path, payload) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------------- 单台删除 ----------------
|
// ---------------- 单台删除 ----------------
|
||||||
document.querySelectorAll('button.del[data-device]').forEach(b => {
|
document.querySelectorAll('button.danger[data-device]').forEach(b => {
|
||||||
b.onclick = async () => {
|
b.onclick = async () => {
|
||||||
const name = b.dataset.name, code = b.dataset.code;
|
const name = b.dataset.name, code = b.dataset.code;
|
||||||
if (!confirm('删除设备「' + name + '」(配对码 ' + code + ')?\n\n'
|
if (!confirm('删除设备「' + name + '」(配对码 ' + code + ')?\n\n'
|
||||||
@@ -301,7 +305,7 @@ function refreshPickState() {
|
|||||||
const btn = document.getElementById('delPicked');
|
const btn = document.getElementById('delPicked');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = chosen === 0;
|
btn.disabled = chosen === 0;
|
||||||
btn.textContent = chosen ? ('删除选中 (' + chosen + ')') : '删除选中';
|
btn.textContent = chosen ? ('删除选中(' + chosen + ')') : '删除选中';
|
||||||
}
|
}
|
||||||
picks().forEach(c => {
|
picks().forEach(c => {
|
||||||
const tr = c.closest('tr');
|
const tr = c.closest('tr');
|
||||||
@@ -329,8 +333,7 @@ if (delPicked) delPicked.onclick = async () => {
|
|||||||
const ids = picked();
|
const ids = picked();
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
if (!confirm('删除选中的 ' + ids.length + ' 台设备?\n\n'
|
if (!confirm('删除选中的 ' + ids.length + ' 台设备?\n\n'
|
||||||
+ '会断开它们现在的手表连接,并清掉它们占的配对码。'))
|
+ '会断开它们现在的手表连接,并清掉它们占的配对码。')) return;
|
||||||
return;
|
|
||||||
delPicked.disabled = true;
|
delPicked.disabled = true;
|
||||||
try {
|
try {
|
||||||
const j = await post('/v1/admin/delete_many', {deviceIds: ids});
|
const j = await post('/v1/admin/delete_many', {deviceIds: ids});
|
||||||
@@ -351,7 +354,7 @@ if (delPicked) delPicked.onclick = async () => {
|
|||||||
// ---------------- 删除全部 ----------------
|
// ---------------- 删除全部 ----------------
|
||||||
const delAll = document.getElementById('delAll');
|
const delAll = document.getElementById('delAll');
|
||||||
if (delAll) delAll.onclick = async () => {
|
if (delAll) delAll.onclick = async () => {
|
||||||
if (!confirm('删除**全部**设备(连同所有配对码)?\n\n'
|
if (!confirm('删除全部设备(连同所有配对码)?\n\n'
|
||||||
+ '清场用,删完所有电脑都要重新设码。')) return;
|
+ '清场用,删完所有电脑都要重新设码。')) return;
|
||||||
try {
|
try {
|
||||||
const j = await post('/v1/admin/delete_all');
|
const j = await post('/v1/admin/delete_all');
|
||||||
@@ -391,83 +394,47 @@ if (netBtn) netBtn.onclick = async () => {
|
|||||||
const r = await fetch('/v1/admin/netcheck');
|
const r = await fetch('/v1/admin/netcheck');
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (!j.ok) {
|
if (!j.ok) {
|
||||||
body.innerHTML = '<span style="color:#f85149">读不到:'
|
body.innerHTML = '<span class="bad">读不到:'
|
||||||
+ (j.error || '未知') + '</span>';
|
+ (j.error || '未知') + '</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rows = [];
|
const rows = [];
|
||||||
if (j.tunnelMetrics) {
|
if (j.tunnelMetrics) {
|
||||||
const conns = j.tunnelConnections || 0;
|
const conns = j.tunnelConnections || 0;
|
||||||
const color = conns >= 2 ? '#1a7f37'
|
const cls = conns >= 2 ? 'good' : 'bad';
|
||||||
: (conns === 1 ? '#9a6700' : '#f85149');
|
rows.push('隧道连接数:<b class="' + cls + '">' + conns + '</b>'
|
||||||
rows.push('隧道连接数:<b style="color:' + color + '">' + conns
|
+ (conns === 0 ? '(断了,看门狗会自动重启)' : ''));
|
||||||
+ '</b>' + (conns === 0 ? '(断了,看门狗会重启)' : ''));
|
rows.push('隧道累计请求:<b>' + (j.tunnelRequests || 0) + '</b>');
|
||||||
rows.push('隧道累计请求:' + (j.tunnelRequests || 0));
|
|
||||||
} else {
|
} else {
|
||||||
rows.push('<span style="color:#9a6700">读不到隧道指标'
|
rows.push('<span class="bad">读不到隧道指标'
|
||||||
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
|
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
|
||||||
}
|
}
|
||||||
rows.push('登记设备:' + j.devices + '(在线 ' + j.online + ')');
|
rows.push('登记设备:<b>' + j.devices + '</b>(在线 <b>' + j.online
|
||||||
rows.push('手表会话:' + j.sessions);
|
+ '</b>)');
|
||||||
rows.push('中转内存:' + Math.round(j.memoryKB / 1024) + ' MB');
|
rows.push('手表会话:<b>' + j.sessions + '</b>');
|
||||||
rows.push('已运行:' + j.uptimeSeconds + ' 秒');
|
rows.push('中转内存:<b>' + Math.round(j.memoryKB / 1024) + ' MB</b>');
|
||||||
|
rows.push('已运行:<b>' + Math.round(j.uptimeSeconds / 60) + ' 分钟</b>');
|
||||||
if (j.publicUrl) {
|
if (j.publicUrl) {
|
||||||
rows.push('公网地址:<code style="letter-spacing:0">'
|
rows.push('公网地址:<b>' + j.publicUrl + '</b>');
|
||||||
+ j.publicUrl + '</code>');
|
|
||||||
}
|
}
|
||||||
body.innerHTML = rows.map(t => '<div style="margin:2px 0">' + t
|
body.innerHTML = rows.map(t => '<div>' + t + '</div>').join('');
|
||||||
+ '</div>').join('');
|
// 顺便测浏览器到中转的往返
|
||||||
// 顺便测一下这台机器的公网往返
|
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
try {
|
try {
|
||||||
await fetch('/v1/health?t=' + Date.now(), {cache: 'no-store'});
|
await fetch('/v1/health?t=' + Date.now(), {cache: 'no-store'});
|
||||||
const ms = Math.round(performance.now() - t0);
|
const ms = Math.round(performance.now() - t0);
|
||||||
body.innerHTML += '<div style="margin:2px 0">浏览器到中转往返:<b>'
|
body.innerHTML += '<div>浏览器到中转往返:<b class="'
|
||||||
+ ms + ' ms</b>'
|
+ (ms < 300 ? 'good' : 'bad') + '">' + ms + ' ms</b>'
|
||||||
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
|
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
body.innerHTML += '<div style="margin:2px 0;color:#f85149">'
|
body.innerHTML += '<div class="bad">浏览器到中转:失败</div>';
|
||||||
+ '浏览器到中转:失败(' + e + ')</div>';
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
body.textContent = '请求出错:' + 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');
|
const autoRef = document.getElementById('autoRef');
|
||||||
let autoTimer = null;
|
let autoTimer = null;
|
||||||
@@ -475,22 +442,22 @@ function setupAuto() {
|
|||||||
clearInterval(autoTimer);
|
clearInterval(autoTimer);
|
||||||
if (autoRef && autoRef.checked) {
|
if (autoRef && autoRef.checked) {
|
||||||
autoTimer = setInterval(() => {
|
autoTimer = setInterval(() => {
|
||||||
// 有勾选/删除操作进行中就先别刷新,免得打断
|
// 有人正在勾选/删除时别刷新,免得打断操作
|
||||||
if (picked().length === 0 && !document.hidden) location.reload();
|
if (picked().length === 0 && !document.hidden) location.reload();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (autoRef) {
|
if (autoRef) {
|
||||||
autoRef.addEventListener('change', () => {
|
autoRef.addEventListener('change', () => {
|
||||||
try { localStorage.setItem('lr_autoref', autoRef.checked ? '1' : '0'); }
|
try {
|
||||||
catch (e) {}
|
localStorage.setItem('lr_autoref', autoRef.checked ? '1' : '0');
|
||||||
|
} catch (e) {}
|
||||||
setupAuto();
|
setupAuto();
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('lr_autoref');
|
if (localStorage.getItem('lr_autoref') === '0') autoRef.checked = false;
|
||||||
if (saved === '0') autoRef.checked = false;
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
setupAuto();
|
setupAuto();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</html>
|
</body></html>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''最终验证 + 提交这一轮(隧道修复 + 管理页重做)。'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
GIT = r"C:\Program Files\Git\bin\git.exe"
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
|
||||||
|
from devssh import run # noqa: E402
|
||||||
|
|
||||||
|
MSG = """隧道"假活"真因修复 + 中转管理后台重做
|
||||||
|
|
||||||
|
用户反馈两件事:
|
||||||
|
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。现在统一定义了常量。
|
||||||
|
|
||||||
|
二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
|
||||||
|
管理页 + 官网 + 关键接口的回归。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def verify() -> None:
|
||||||
|
print(" 一、验证配对码清理")
|
||||||
|
out = run(
|
||||||
|
"curl -s -b /tmp/ck.txt --max-time 20 "
|
||||||
|
"http://127.0.0.1:8080/zhongzhuanzhan | "
|
||||||
|
"grep -A2 '配对码占用' | head -3",
|
||||||
|
timeout=120)[1]
|
||||||
|
print(" " + (out or "").strip())
|
||||||
|
|
||||||
|
code = run(
|
||||||
|
"curl -s --max-time 10 http://127.0.0.1:8080/v1/health | "
|
||||||
|
"python3 -c \"import sys,json; d=json.load(sys.stdin); "
|
||||||
|
"print('设备', len(d.get('devices',[])))\"",
|
||||||
|
timeout=60)[1]
|
||||||
|
print(" " + (code or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def commit() -> None:
|
||||||
|
print()
|
||||||
|
print(" 二、提交")
|
||||||
|
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
|
||||||
|
msg_file = ROOT / "_build" / "_cmsg7.txt"
|
||||||
|
msg_file.write_bytes(MSG.encode("utf-8"))
|
||||||
|
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
|
||||||
|
cwd=str(ROOT), capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
msg_file.unlink(missing_ok=True)
|
||||||
|
print(" commit exit=%s" % r.returncode)
|
||||||
|
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
errors="replace")
|
||||||
|
print(" " + (show.stdout or "").strip())
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
|
||||||
|
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
errors="replace", env=env)
|
||||||
|
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
|
||||||
|
print(" " + line)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
verify()
|
||||||
|
commit()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 对照实验:QUIC vs HTTP/2,连接数 2 vs 4。
|
||||||
|
#
|
||||||
|
# 背景:
|
||||||
|
# cloudflared 自己的 precheck 说 "TCP 7844 blocked"(运营商墙了),
|
||||||
|
# 而 --protocol http2 走的正是 TCP 7844。
|
||||||
|
# UDP 那边 precheck 显示 region1 成功、region2 失败 —— 部分可用。
|
||||||
|
#
|
||||||
|
# 实测公网:/favicon.ico 能 200,其它路径 530。
|
||||||
|
# 530 = 边缘找不到可用的隧道连接(请求落 SEA,隧道在 LAX)。
|
||||||
|
#
|
||||||
|
# 所以要把"协议 + 连接数"这四种组合都跑一遍,用数据选,不靠猜。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PUB=https://relay.xxmxxs.top
|
||||||
|
UNIT=/etc/systemd/system/lanremote-tunnel.service
|
||||||
|
RELAY_DIR=/root/lanremote-relay
|
||||||
|
|
||||||
|
probe() {
|
||||||
|
# 返回 "可用数/总数"
|
||||||
|
local ok=0
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \
|
||||||
|
-H 'User-Agent: LanRemote/1.4.0' "$PUB/v1/health")
|
||||||
|
[ "$c" = "200" ] && ok=$((ok + 1))
|
||||||
|
sleep 0.6
|
||||||
|
done
|
||||||
|
echo "$ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_combo() {
|
||||||
|
local proto="$1" conns="$2"
|
||||||
|
echo " ---- 设置 protocol=$proto ha-connections=$conns ----"
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$UNIT"
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$UNIT"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
sleep 35
|
||||||
|
}
|
||||||
|
|
||||||
|
report() {
|
||||||
|
local label="$1"
|
||||||
|
local conns
|
||||||
|
conns=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
local ok
|
||||||
|
ok=$(probe)
|
||||||
|
printf " %-28s 连接 %-3s 公网 %2s/12\n" "$label" "${conns:-?}" "$ok"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "════════ 对照实验开始(每种组合约 1.5 分钟)════════"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "组合 1/4:HTTP/2 + 2 连接"
|
||||||
|
set_combo http2 2
|
||||||
|
report "http2 + 2"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "组合 2/4:HTTP/2 + 4 连接"
|
||||||
|
set_combo http2 4
|
||||||
|
report "http2 + 4"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "组合 3/4:QUIC + 2 连接"
|
||||||
|
set_combo quic 2
|
||||||
|
report "quic + 2"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "组合 4/4:QUIC + 4 连接"
|
||||||
|
set_combo quic 4
|
||||||
|
report "quic + 4"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 各组合的落地机房 ════════"
|
||||||
|
grep -E 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null | tail -8 \
|
||||||
|
| grep -oE 'location=[a-z0-9]+ protocol=[a-z0-9]+' | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 说明:这个脚本只做测量,最终选哪种组合由数据定,"
|
||||||
|
echo " 跑完我会把最优的一组写回 unit 并重启。"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 隧道还是不稳 —— 深挖一层
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo "════════ 1. 服务与连接 ════════"
|
||||||
|
echo " relay: $(systemctl is-active lanremote-relay)"
|
||||||
|
echo " tunnel: $(systemctl is-active lanremote-tunnel)"
|
||||||
|
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. watchdog 最近 20 行(它在报什么)════════"
|
||||||
|
tail -20 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. watchdog 是怎么被调起来的 ════════"
|
||||||
|
systemctl is-active lanremote-watchdog 2>&1 | sed 's/^/ 服务: /'
|
||||||
|
crontab -l 2>/dev/null | grep -i watch | sed 's/^/ cron: /'
|
||||||
|
ls -la /etc/systemd/system/ | grep -i watch | sed 's/^/ unit: /'
|
||||||
|
ps aux 2>/dev/null | grep '[w]atchdog' | head -3 | sed 's/^/ 进程: /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. 隧道日志最后 25 行 ════════"
|
||||||
|
tail -25 /root/lanremote-relay/tunnel.log 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. 本机 relay 通不通 ════════"
|
||||||
|
for i in 1 2 3; do
|
||||||
|
printf " 本机 %d: HTTP %s\n" "$i" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:8080/v1/health)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 6. 公网连打 10 次看规律 ════════"
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
curl -s -o /dev/null -w " $i: HTTP %{http_code} %{time_total}s\n" \
|
||||||
|
--max-time 20 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 7. 隧道出口 IP 的可达性(现在还通吗)════════"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
# 看隧道日志里出现过的 IP
|
||||||
|
ips = ["198.41.192.107", "198.41.200.33", "198.41.192.227",
|
||||||
|
"198.41.200.63", "198.41.192.37"]
|
||||||
|
for ip in ips:
|
||||||
|
s = socket.socket()
|
||||||
|
s.settimeout(3)
|
||||||
|
t = time.perf_counter()
|
||||||
|
try:
|
||||||
|
s.connect((ip, 7844))
|
||||||
|
print(" %-16s %.0f ms" % (ip, (time.perf_counter() - t) * 1000))
|
||||||
|
except OSError as e:
|
||||||
|
print(" %-16s 不通 (%s)" % (ip, e.__class__.__name__))
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 8. 网络本身的质量 ════════"
|
||||||
|
echo " 丢包测试(ping 1.1.1.1 10 次):"
|
||||||
|
ping -c 10 -W 2 1.1.1.1 2>/dev/null | tail -3 | sed 's/^/ /'
|
||||||
|
echo " 到 Cloudflare 边缘的丢包:"
|
||||||
|
ping -c 10 -W 2 198.41.192.107 2>/dev/null | tail -3 | sed 's/^/ /'
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 管理页 000 —— 直接打,看真实响应
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=http://127.0.0.1:8080
|
||||||
|
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||||
|
|
||||||
|
echo " key 长度: ${#KEY}"
|
||||||
|
echo
|
||||||
|
echo "════════ 1. 带 X-Admin 头 ════════"
|
||||||
|
curl -sv -H "X-Admin: $KEY" --max-time 15 "$BASE/zhongzhuanzhan" 2>&1 \
|
||||||
|
| grep -E '^< HTTP|^< |Connection|Empty' | head -12 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. 不带钥匙(应该看到登录页)════════"
|
||||||
|
curl -s -o /tmp/lg.html -w ' HTTP %{http_code} %{size_download} 字节\n' --max-time 15 "$BASE/zhongzhuanzhan"
|
||||||
|
head -c 200 /tmp/lg.html | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. 直接用 cookie 登录一次再访 ════════"
|
||||||
|
PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null)
|
||||||
|
echo " 密码长度: ${#PASS}"
|
||||||
|
curl -s -c /tmp/ck.txt -o /dev/null --max-time 15 \
|
||||||
|
-d "username=xmz7891&password=$PASS" "$BASE/admin/login"
|
||||||
|
echo " cookie:"
|
||||||
|
cat /tmp/ck.txt 2>/dev/null | grep -v '^#' | sed 's/^/ /'
|
||||||
|
echo " 用 cookie 访管理页:"
|
||||||
|
curl -s -b /tmp/ck.txt -o /tmp/ad.html -w ' HTTP %{http_code} %{size_download} 字节\n' \
|
||||||
|
--max-time 15 "$BASE/zhongzhuanzhan"
|
||||||
|
echo " 前 300 字符:"
|
||||||
|
head -c 300 /tmp/ad.html | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. relay 日志里最近的错误 ════════"
|
||||||
|
journalctl -u lanremote-relay --since '3 minutes ago' --no-pager 2>/dev/null \
|
||||||
|
| tail -30 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. relay.log 尾部 ════════"
|
||||||
|
tail -20 /root/lanremote-relay/relay.log 2>/dev/null | sed 's/^/ /'
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 502 但隧道连着 -> 回源这一段的问题。逐一排除。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo "════════ 1. 隧道 ingress 全文 ════════"
|
||||||
|
cat /root/.cloudflared/config.yml 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. relay 在听哪个地址 ════════"
|
||||||
|
ss -lntp 2>/dev/null | grep -E ':8080' | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. 带 Host 头访问(模拟云隧道回源)════════"
|
||||||
|
for host in "relay.xxmxxs.top" "127.0.0.1" "localhost" ""; do
|
||||||
|
if [ -z "$host" ]; then
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:8080/v1/health)
|
||||||
|
echo " 不带 Host: HTTP $code"
|
||||||
|
else
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 -H "Host: $host" http://127.0.0.1:8080/v1/health)
|
||||||
|
echo " Host: $host -> HTTP $code"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. 请求 /(官网)带 Host 头 ════════"
|
||||||
|
for host in "relay.xxmxxs.top" "127.0.0.1"; do
|
||||||
|
printf " Host: %-22s HTTP %s\n" "$host" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 -H "Host: $host" http://127.0.0.1:8080/)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. 隧道进程的真实参数 ════════"
|
||||||
|
pid=$(pgrep -f 'cloudflared tunnel' | head -1)
|
||||||
|
[ -n "$pid" ] && tr '\0' ' ' < /proc/$pid/cmdline 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "════════ 6. cloudflared 有没有报回源错误 ════════"
|
||||||
|
tail -400 /root/lanremote-relay/tunnel.log 2>/dev/null \
|
||||||
|
| grep -iE 'origin|502|refused|unreachable|no such host|proxy' | tail -10 | sed 's/^/ /'
|
||||||
|
echo " (没输出=没有回源错误)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 7. relay 自己有没有收到隧道来的请求 ════════"
|
||||||
|
tail -60 /root/lanremote-relay/relay.log 2>/dev/null \
|
||||||
|
| grep -E 'GET|POST|处理' | tail -10 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 8. 关键:直接问 Cloudflare 边缘这个域名该怎么走 ════════"
|
||||||
|
echo " DNS 记录:"
|
||||||
|
getent hosts relay.xxmxxs.top | sed 's/^/ /'
|
||||||
|
echo " HTTP 响应头(看 cf-ray / server):"
|
||||||
|
curl -sI --max-time 20 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health \
|
||||||
|
| head -12 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 9. 换一个路径试(避开 /v1/health)════════"
|
||||||
|
for p in "/" "/favicon.ico" "/v1/health" "/v1/downloads"; do
|
||||||
|
printf " %-16s HTTP %s\n" "$p" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H 'User-Agent: LanRemote/1.4.0' "https://relay.xxmxxs.top$p")"
|
||||||
|
done
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -u
|
||||||
|
echo " relay: $(systemctl is-active lanremote-relay)"
|
||||||
|
echo
|
||||||
|
echo " 最近日志:"
|
||||||
|
journalctl -u lanremote-relay -n 30 --no-pager 2>/dev/null | tail -22 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " 手动 import 看报什么:"
|
||||||
|
cd /root/lanremote-relay
|
||||||
|
python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
try:
|
||||||
|
import relay
|
||||||
|
print(' import relay OK')
|
||||||
|
except Exception:
|
||||||
|
import traceback; traceback.print_exc()
|
||||||
|
" 2>&1 | tail -15 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " admin 模块:"
|
||||||
|
python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
try:
|
||||||
|
import admin
|
||||||
|
print(' import admin OK')
|
||||||
|
except Exception:
|
||||||
|
import traceback; traceback.print_exc()
|
||||||
|
" 2>&1 | tail -15 | sed 's/^/ /'
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# relay 起不来 —— 直接在设备上手动跑,看真实报错
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo " 服务状态: $(systemctl is-active lanremote-relay)"
|
||||||
|
echo " restart 计数: $(systemctl show lanremote-relay -p NRestarts --value)"
|
||||||
|
echo
|
||||||
|
echo " 最近 journal:"
|
||||||
|
journalctl -u lanremote-relay -n 15 --no-pager 2>/dev/null | tail -10 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 手动前台跑 5 秒(看它自己报什么)════════"
|
||||||
|
cd /root/lanremote-relay
|
||||||
|
timeout 5 python3 relay.py 2>&1 | head -25 | sed 's/^/ /'
|
||||||
|
echo " (退出码 $?)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 单独 import + 调 main 的前半段 ════════"
|
||||||
|
python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
import relay
|
||||||
|
print(' VERSION =', relay.VERSION)
|
||||||
|
print(' PORT =', relay.PORT, 'BIND =', relay.BIND)
|
||||||
|
print(' ADMIN_PATH =', relay.ADMIN_PATH)
|
||||||
|
print(' main 存在:', callable(getattr(relay, 'main', None)))
|
||||||
|
" 2>&1 | tail -12 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 端口有没有被占 ════════"
|
||||||
|
ss -lntp 2>/dev/null | grep -E ':8080' | sed 's/^/ /'
|
||||||
|
echo " (没输出=没人占)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 数据目录权限 ════════"
|
||||||
|
ls -la /root/lanremote-relay/data/ 2>/dev/null | head -8 | sed 's/^/ /'
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -u
|
||||||
|
echo " relay 状态: $(systemctl is-active lanremote-relay)"
|
||||||
|
echo
|
||||||
|
echo " 最近的错误:"
|
||||||
|
journalctl -u lanremote-relay -n 40 --no-pager 2>/dev/null | tail -25 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " 直接跑一下看报什么错:"
|
||||||
|
cd /root/lanremote-relay
|
||||||
|
python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
try:
|
||||||
|
import relay
|
||||||
|
print(' import OK')
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
" 2>&1 | tail -20 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " landing 单独渲染:"
|
||||||
|
python3 -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
try:
|
||||||
|
import landing
|
||||||
|
html = landing.render_site([{'name':'ykxmz-setup-1.7.1.exe','size':15063814,'mtime':1}])
|
||||||
|
print(' 渲染 OK,%d 字符' % len(html))
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
" 2>&1 | tail -20 | sed 's/^/ /'
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''对比 git 版本,找出 swap_admin_page.py 误删的所有东西。
|
||||||
|
|
||||||
|
事故:
|
||||||
|
那个脚本把 relay.py 从 "_admin_page" 那行一直截到文件末尾,
|
||||||
|
以为 _admin_page 是最后一个东西。实际它后面还有:
|
||||||
|
- 一大堆模块级函数/常量(ADMIN_PATH、Server、Handler...)
|
||||||
|
- main() 入口
|
||||||
|
结果 relay 起不来,systemd 重启 14 次。
|
||||||
|
|
||||||
|
这个脚本用 AST 对比两边的**顶层名字**,把丢了的一次性列清楚。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
RELAY = ROOT / "relay" / "relay.py"
|
||||||
|
GIT = r"C:\Program Files\Git\bin\git.exe"
|
||||||
|
|
||||||
|
|
||||||
|
def top_names(source: str) -> list:
|
||||||
|
"""列出模块顶层的定义名(按出现顺序)。"""
|
||||||
|
tree = ast.parse(source)
|
||||||
|
out = []
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
out.append(("def", node.name, node.lineno))
|
||||||
|
elif isinstance(node, ast.ClassDef):
|
||||||
|
out.append(("class", node.name, node.lineno))
|
||||||
|
elif isinstance(node, ast.Assign):
|
||||||
|
for target in node.targets:
|
||||||
|
if isinstance(target, ast.Name):
|
||||||
|
out.append(("var", target.id, node.lineno))
|
||||||
|
elif isinstance(node, ast.AnnAssign) and isinstance(node.target,
|
||||||
|
ast.Name):
|
||||||
|
out.append(("var", node.target.id, node.lineno))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
old = subprocess.run([GIT, "show", "HEAD:relay/relay.py"],
|
||||||
|
cwd=str(ROOT), capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace").stdout
|
||||||
|
new = RELAY.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if not old:
|
||||||
|
print(" [失败] 取不到 git 版本")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
old_names = top_names(old)
|
||||||
|
new_names = top_names(new)
|
||||||
|
new_set = {n[1] for n in new_names}
|
||||||
|
|
||||||
|
missing = [item for item in old_names if item[1] not in new_set]
|
||||||
|
|
||||||
|
print(" git 版本顶层定义 %d 个,当前 %d 个"
|
||||||
|
% (len(old_names), len(new_names)))
|
||||||
|
print()
|
||||||
|
if not missing:
|
||||||
|
print(" 没有缺失 ✓")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(" 缺失 %d 个:" % len(missing))
|
||||||
|
for kind, name, lineno in missing:
|
||||||
|
print(" %-6s %-28s (git 第 %d 行)" % (kind, name, lineno))
|
||||||
|
|
||||||
|
# 把缺失的部分从 git 里摘出来
|
||||||
|
old_lines = old.splitlines()
|
||||||
|
print()
|
||||||
|
print(" 缺失部分在 git 里的行范围:")
|
||||||
|
# 找最小的起止行
|
||||||
|
starts = [item[2] for item in missing]
|
||||||
|
print(" 从第 %d 行到文件末尾(共 %d 行)"
|
||||||
|
% (min(starts), len(old_lines) - min(starts) + 1))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 这些行的开头(确认是什么内容):")
|
||||||
|
for i in range(min(starts) - 1, min(len(old_lines),
|
||||||
|
min(starts) + 30)):
|
||||||
|
line = old_lines[i]
|
||||||
|
if line.strip():
|
||||||
|
print(" %5d: %s" % (i + 1, line[:84]))
|
||||||
|
|
||||||
|
# 存一份给恢复脚本用
|
||||||
|
out = ROOT / "_build" / "_missing_tail.py"
|
||||||
|
out.write_text("\n".join(old_lines[min(starts) - 1:]) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
print()
|
||||||
|
print(" 缺失的整段已存到 _build/_missing_tail.py")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+49
-99
@@ -1,117 +1,67 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 定稿:ha-connections=2,并做长时间稳定性验证。
|
# 定稿:HTTP/2 + 4 连接,然后做**长时间**验证(这次不看瞬时值)
|
||||||
#
|
|
||||||
# 实测(见 /root/ha_result.txt):
|
|
||||||
# ha=4 -> 9/10,而且只成功注册 1 条连接(其余一直在握手超时)
|
|
||||||
# ha=2 -> 10/10,2 条都注册上
|
|
||||||
# ha=1 -> 10/10,1 条
|
|
||||||
#
|
|
||||||
# 原因很可能是这条 4G 线路对并发 UDP 有节制:
|
|
||||||
# 4 条 QUIC 同时握手会互相挤,反而条条失败。
|
|
||||||
# ha=2 是甜点:够稳,又还有冗余。
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
RELAY_DIR=/root/lanremote-relay
|
|
||||||
UNIT=/etc/systemd/system/lanremote-tunnel.service
|
UNIT=/etc/systemd/system/lanremote-tunnel.service
|
||||||
|
PUB=https://relay.xxmxxs.top
|
||||||
|
RELAY_DIR=/root/lanremote-relay
|
||||||
|
|
||||||
echo "===== 1. 写入正式配置(ha=2)====="
|
echo "════════ 1. 写回最优配置 ════════"
|
||||||
cat > "$UNIT" <<'EOF'
|
sed -i "s/--protocol [a-z0-9]*/--protocol http2/" "$UNIT"
|
||||||
[Unit]
|
sed -i "s/--ha-connections [0-9]*/--ha-connections 4/" "$UNIT"
|
||||||
Description=LanRemote Cloudflare 具名隧道
|
|
||||||
After=network-online.target lanremote-relay.service
|
|
||||||
# 用 Wants 不用 Requires:Requires 是双向的,
|
|
||||||
# 重启中转会把隧道一起停掉,而且没东西会再拉起来。
|
|
||||||
Wants=network-online.target lanremote-relay.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
# 协议:必须 QUIC(默认)。
|
|
||||||
# 实测这条线路把 HTTP/2 用的 TCP 7844 挡了(TLS 握手超时),
|
|
||||||
# 而 QUIC 走 UDP 7844 是通的。
|
|
||||||
# 连接数:ha-connections=2。
|
|
||||||
# 实测 4 条并发 QUIC 握手会互相挤,只成功 1 条、可用率 9/10;
|
|
||||||
# 降到 2 条后 10/10,且仍有冗余(连两个不同边缘节点)。
|
|
||||||
# IPv4:这条线路有 IPv6 地址但实际不通,必须 --edge-ip-version 4。
|
|
||||||
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --edge-ip-version 4 --ha-connections 2 --retries 5 --config /root/.cloudflared/config.yml run lanremote
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
MemoryMax=128M
|
|
||||||
StandardOutput=append:/root/lanremote-relay/tunnel.log
|
|
||||||
StandardError=append:/root/lanremote-relay/tunnel.log
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
systemctl stop lanremote-tunnel 2>/dev/null || true
|
|
||||||
pkill -f cloudflared 2>/dev/null || true
|
|
||||||
sleep 3
|
|
||||||
rm -f "$RELAY_DIR/tunnel.log"
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable lanremote-tunnel >/dev/null 2>&1
|
systemctl restart lanremote-tunnel
|
||||||
systemctl start lanremote-tunnel
|
echo " $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$UNIT" | tr '\n' ' ')"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "===== 2. 等它建齐连接 ====="
|
echo "════════ 2. 等连接全部建立 ════════"
|
||||||
for i in 1 2 3 4 5; do
|
for i in $(seq 1 12); do
|
||||||
sleep 15
|
sleep 5
|
||||||
N=$(grep -c 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null || echo 0)
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
echo " $((i*15)) 秒:已注册 $N 条"
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
[ "$N" -ge 2 ] && break
|
echo " ${i}0s 内: 连接 $n"
|
||||||
|
[ "$n" = "4" ] && break
|
||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "===== 3. 长时间稳定性:连打 30 次,每次隔 3 秒 ====="
|
echo "════════ 3. 公网连测 40 次(约 2 分钟)════════"
|
||||||
OK=0
|
ok=0; fail=0; worst=0; streak=0; worst_streak=0
|
||||||
FAIL=0
|
for i in $(seq 1 40); do
|
||||||
FAILED_CODES=""
|
r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 15 \
|
||||||
for i in $(seq 1 30); do
|
-H 'User-Agent: LanRemote/1.4.0' "$PUB/v1/health")
|
||||||
C=$(timeout 20 curl -4 -s -o /dev/null -w '%{http_code}' \
|
c=$(echo "$r" | cut -d' ' -f1)
|
||||||
-H 'User-Agent: LanRemote/1.4.0' \
|
t=$(echo "$r" | cut -d' ' -f2)
|
||||||
https://relay.xxmxxs.top/v1/health 2>/dev/null)
|
if [ "$c" = "200" ]; then
|
||||||
if [ "$C" = "200" ]; then
|
ok=$((ok+1)); streak=0
|
||||||
OK=$((OK + 1)); M="✓"
|
big=$(echo "$t > $worst" | bc 2>/dev/null || echo 0)
|
||||||
|
[ "$big" = "1" ] && worst=$t
|
||||||
else
|
else
|
||||||
FAIL=$((FAIL + 1)); M="✗"; FAILED_CODES="$FAILED_CODES ${C:-超时}"
|
fail=$((fail+1)); streak=$((streak+1))
|
||||||
|
[ "$streak" -gt "$worst_streak" ] && worst_streak=$streak
|
||||||
|
[ $((i % 5)) -eq 0 ] && echo " 第 $i 次: $c"
|
||||||
fi
|
fi
|
||||||
printf " %2d: %s %s\n" "$i" "$M" "${C:-超时}"
|
sleep 2.5
|
||||||
sleep 3
|
|
||||||
done
|
done
|
||||||
PCT=$((OK * 100 / 30))
|
rate=$(echo "scale=1; $ok * 100 / 40" | bc)
|
||||||
echo
|
echo
|
||||||
echo " 成功 $OK / 30 ($PCT%)"
|
echo " 可用 $ok/40 = ${rate}% 最慢 ${worst}s"
|
||||||
[ -n "$FAILED_CODES" ] && echo " 失败的返回码:$FAILED_CODES"
|
echo " 最长连续失败 $worst_streak 次(约 $((worst_streak * 3)) 秒)"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "===== 4. 对照:中转本地(应该 100%)====="
|
echo "════════ 4. 这段时间隧道重连了几次 ════════"
|
||||||
LOK=0
|
since=$(date -u -d '4 minutes ago' '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "")
|
||||||
for i in $(seq 1 10); do
|
if [ -n "$since" ]; then
|
||||||
C=$(timeout 8 curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/v1/health)
|
awk -v s="$since" '$0 >= s' "$RELAY_DIR/tunnel.log" 2>/dev/null > /tmp/recent.log
|
||||||
[ "$C" = "200" ] && LOK=$((LOK + 1))
|
for kw in 'Registered tunnel connection' 'Lost connection' 'Retrying connection' 'i/o timeout'; do
|
||||||
done
|
printf " %-32s %s 次\n" "$kw" "$(grep -c "$kw" /tmp/recent.log)"
|
||||||
echo " 局域网直连 $LOK / 10"
|
done
|
||||||
|
|
||||||
echo
|
|
||||||
echo "===== 5. 隧道日志统计 ====="
|
|
||||||
echo " 注册成功: $(grep -c 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null || echo 0)"
|
|
||||||
echo " 连接断开: $(grep -c 'Connection terminated' "$RELAY_DIR/tunnel.log" 2>/dev/null || echo 0)"
|
|
||||||
echo " quic 错误: $(grep -c 'Failed to dial a quic' "$RELAY_DIR/tunnel.log" 2>/dev/null || echo 0)"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "===== 6. 最终服务清单 ====="
|
|
||||||
for s in lanremote-relay lanremote-tunnel lanremote-watchdog.timer; do
|
|
||||||
printf " %-32s %-10s %s\n" "$s" \
|
|
||||||
"$(systemctl is-active $s 2>/dev/null)" \
|
|
||||||
"$(systemctl is-enabled $s 2>/dev/null)"
|
|
||||||
done
|
|
||||||
echo
|
|
||||||
echo " 公网地址: $(cat $RELAY_DIR/public_url.txt)"
|
|
||||||
echo " 内存: $(free -m | awk 'NR==2{printf "已用 %s MB / 可用 %s MB", $3, $7}')"
|
|
||||||
|
|
||||||
if [ "$PCT" -ge 90 ]; then
|
|
||||||
echo
|
|
||||||
echo " ✓ 稳定了($PCT%)—— ha-connections=2 定稿"
|
|
||||||
else
|
|
||||||
echo
|
|
||||||
echo " ~ 还有抖动($PCT%),看门狗会兜底"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. 现在的落点 ════════"
|
||||||
|
grep -E 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null | tail -5 \
|
||||||
|
| grep -oE 'ip=[0-9.]+ location=[a-z0-9]+ protocol=[a-z0-9]+' | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 6. watchdog 状态 ════════"
|
||||||
|
tail -6 "$RELAY_DIR/watchdog.log" 2>/dev/null | sed 's/^/ /'
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''抓新版管理页到本地做截图(要带登录 cookie)。'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
|
||||||
|
from devssh import run # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# 在设备上登录拿 cookie,再抓页面,然后把 cookie 去掉后存本地
|
||||||
|
_c, out, _e = run(
|
||||||
|
"PASS=$(cat /root/lanremote-relay/data/admin_password.txt); "
|
||||||
|
"curl -s -c /tmp/ck.txt -o /dev/null "
|
||||||
|
"-d \"username=xmz7891&password=$PASS\" "
|
||||||
|
"http://127.0.0.1:8080/admin/login; "
|
||||||
|
"curl -s -b /tmp/ck.txt --max-time 20 "
|
||||||
|
"http://127.0.0.1:8080/zhongzhuanzhan",
|
||||||
|
timeout=120)
|
||||||
|
if not out or "ykxmz 中转站" not in out:
|
||||||
|
print(" 抓不到管理页")
|
||||||
|
print(out[:300])
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 本地 file:// 打开时相对路径全失效,改绝对
|
||||||
|
out = out.replace('href="/favicon.ico"',
|
||||||
|
'href="https://relay.xxmxxs.top/favicon.ico"')
|
||||||
|
|
||||||
|
target = ROOT / "_build" / "admin_new.html"
|
||||||
|
target.write_text(out, encoding="utf-8")
|
||||||
|
print(" 抓到 %d 字符 -> %s" % (len(out), target.name))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''启动被控端,把它的窗口标题都列出来(实测比翻代码快)。'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from ctypes import wintypes
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
EXE = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe"
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
|
||||||
|
|
||||||
|
def windows_of(pid: int) -> list:
|
||||||
|
out = []
|
||||||
|
|
||||||
|
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||||||
|
def cb(hwnd, _lp):
|
||||||
|
wpid = wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(wpid))
|
||||||
|
if wpid.value == pid:
|
||||||
|
length = user32.GetWindowTextLengthW(hwnd)
|
||||||
|
buf = ctypes.create_unicode_buffer(length + 1)
|
||||||
|
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||||
|
cls = ctypes.create_unicode_buffer(256)
|
||||||
|
user32.GetClassNameW(hwnd, cls, 256)
|
||||||
|
rect = wintypes.RECT()
|
||||||
|
user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||||
|
out.append({
|
||||||
|
"hwnd": hwnd,
|
||||||
|
"title": buf.value,
|
||||||
|
"class": cls.value,
|
||||||
|
"visible": bool(user32.IsWindowVisible(hwnd)),
|
||||||
|
"size": (rect.right - rect.left, rect.bottom - rect.top),
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
user32.EnumWindows(cb, 0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
data = ROOT / "_build" / "_shotdata2"
|
||||||
|
data.mkdir(parents=True, exist_ok=True)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["YKXMZ_DATA"] = str(data)
|
||||||
|
|
||||||
|
print(" 启动被控端(不带 --silent,应该出主窗口)")
|
||||||
|
proc = subprocess.Popen([str(EXE), "--port", "8898"],
|
||||||
|
cwd=str(ROOT), env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL)
|
||||||
|
time.sleep(12)
|
||||||
|
|
||||||
|
wins = windows_of(proc.pid)
|
||||||
|
print(" 进程 PID=%d 的窗口(%d 个):" % (proc.pid, len(wins)))
|
||||||
|
for w in wins:
|
||||||
|
print(" hwnd=%d 可见=%s 大小=%s 类=%s"
|
||||||
|
% (w["hwnd"], w["visible"], w["size"], w["class"]))
|
||||||
|
print(" 标题: %r" % w["title"])
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 提醒:PyInstaller --onedir 启动后,真正跑 GUI 的可能是子进程。")
|
||||||
|
print(" 所有 python/ykxmz 进程:")
|
||||||
|
r = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-Command",
|
||||||
|
"Get-Process ykxmz,python -ErrorAction SilentlyContinue | "
|
||||||
|
"ForEach-Object { $_.Id.ToString() + ' ' + $_.ProcessName }"],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
print(" " + (r.stdout or "").replace("\n", "\n "))
|
||||||
|
|
||||||
|
# 子进程的窗口也列一下
|
||||||
|
for line in (r.stdout or "").splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
child = int(parts[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if child == proc.pid:
|
||||||
|
continue
|
||||||
|
for w in windows_of(child):
|
||||||
|
if w["visible"] and w["title"]:
|
||||||
|
print(" [子进程 %d] hwnd=%d 大小=%s 标题=%r"
|
||||||
|
% (child, w["hwnd"], w["size"], w["title"]))
|
||||||
|
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
subprocess.run(["powershell", "-NoProfile", "-Command",
|
||||||
|
"Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||||
|
"Stop-Process -Force"],
|
||||||
|
capture_output=True, timeout=60)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''官网细节打磨。
|
||||||
|
|
||||||
|
看截图发现的问题:
|
||||||
|
1. 导航还是老的四个链接,新增的「长什么样」「怎么连上的」进不去
|
||||||
|
2. 手表 mockup 的状态栏「已连接 · 局域网 3 ms」被挤到换行
|
||||||
|
3. 连接示意的图标(⌚☁🖥)太小太淡,看不清
|
||||||
|
4. 电脑端截图直接贴了窗口标题栏,显得毛糙 —— 加个浅色边框和内边距
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
LANDING = ROOT / "relay" / "landing.py"
|
||||||
|
SHOWCASE = ROOT / "relay" / "showcase.py"
|
||||||
|
|
||||||
|
|
||||||
|
def polish_landing() -> None:
|
||||||
|
text = LANDING.read_text(encoding="utf-8")
|
||||||
|
changed = []
|
||||||
|
|
||||||
|
# 1) 导航加链接
|
||||||
|
old_nav = ''' <div class="nav-links">
|
||||||
|
<a href="#features">功能</a>
|
||||||
|
<a href="#how">怎么用</a>
|
||||||
|
<a href="#download">下载</a>
|
||||||
|
<a href="#faq">常见问题</a>
|
||||||
|
</div>'''
|
||||||
|
new_nav = ''' <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>
|
||||||
|
</div>'''
|
||||||
|
if old_nav in text:
|
||||||
|
text = text.replace(old_nav, new_nav, 1)
|
||||||
|
changed.append("导航链接")
|
||||||
|
|
||||||
|
LANDING.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
try:
|
||||||
|
ast.parse(text)
|
||||||
|
print(" landing.py 语法 OK")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
print(" [失败] landing.py 行 %s: %s" % (exc.lineno, exc.msg))
|
||||||
|
for item in changed:
|
||||||
|
print(" 改了: %s" % item)
|
||||||
|
|
||||||
|
|
||||||
|
def polish_showcase() -> None:
|
||||||
|
text = SHOWCASE.read_text(encoding="utf-8")
|
||||||
|
changed = []
|
||||||
|
|
||||||
|
# 2) 状态栏:把「已连接 · 局域网 3 ms」拆短,别挤
|
||||||
|
old = '<span class="w-conn">● 已连接 · 局域网 3 ms</span>'
|
||||||
|
new = '<span class="w-conn">● 局域网 3 ms</span>'
|
||||||
|
if old in text:
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
changed.append("状态栏文案")
|
||||||
|
|
||||||
|
# 3) 图标放大 + 连线加粗,并给节点一个底色圆
|
||||||
|
css_fixes = [
|
||||||
|
# 节点图标更大更清晰
|
||||||
|
(".node{display:flex;flex-direction:column;align-items:center;gap:4px;\n"
|
||||||
|
" font-size:22px;flex:none}",
|
||||||
|
".node{display:flex;flex-direction:column;align-items:center;gap:6px;\n"
|
||||||
|
" font-size:26px;flex:none;width:64px}"),
|
||||||
|
(".node span{font-size:11px;color:#6b7280}",
|
||||||
|
".node span{font-size:12px;color:#4b5563;font-weight:500}"),
|
||||||
|
(".node.relay{font-size:19px}",
|
||||||
|
".node.relay{font-size:24px}"),
|
||||||
|
# 连线更明显
|
||||||
|
(".link .line{width:100%%;height:2px;background:#d1d5db;border-radius:2px}",
|
||||||
|
".link .line{width:100%%;height:2px;background:#c7cdd6;\n"
|
||||||
|
" border-radius:2px}"),
|
||||||
|
# 手表状态栏字号再小一点,保证一行放得下
|
||||||
|
(".w-status{display:flex;justify-content:space-between;align-items:center;\n"
|
||||||
|
" padding:11px 13px 7px;font-size:10px;font-weight:600}",
|
||||||
|
".w-status{display:flex;justify-content:space-between;align-items:center;\n"
|
||||||
|
" padding:11px 12px 7px;font-size:9px;font-weight:600;\n"
|
||||||
|
" gap:6px;white-space:nowrap}"),
|
||||||
|
# 电脑端截图:加个浅色容器,别让窗口标题栏直接贴边
|
||||||
|
(".pcshot img{width:100%%;max-width:360px;height:auto;\n"
|
||||||
|
" border:1px solid %(W_BORDER)s;border-radius:12px;\n"
|
||||||
|
" box-shadow:0 18px 40px rgba(16,24,40,.14)}",
|
||||||
|
".pcshot img{width:100%%;max-width:352px;height:auto;\n"
|
||||||
|
" border-radius:10px;display:block;margin:0 auto;\n"
|
||||||
|
" box-shadow:0 18px 40px rgba(16,24,40,.16)}\n"
|
||||||
|
".pcshot{padding:14px;background:#f2f4f7;border-radius:16px;\n"
|
||||||
|
" border:1px solid %(W_BORDER)s}"),
|
||||||
|
]
|
||||||
|
for old_css, new_css in css_fixes:
|
||||||
|
if old_css in text:
|
||||||
|
text = text.replace(old_css, new_css, 1)
|
||||||
|
changed.append("样式")
|
||||||
|
|
||||||
|
SHOWCASE.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
try:
|
||||||
|
ast.parse(text)
|
||||||
|
print(" showcase.py 语法 OK")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
print(" [失败] showcase.py 行 %s: %s" % (exc.lineno, exc.msg))
|
||||||
|
print(" 改了 %d 处" % len(changed))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print(" 一、landing.py")
|
||||||
|
polish_landing()
|
||||||
|
print()
|
||||||
|
print(" 二、showcase.py")
|
||||||
|
polish_showcase()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把被我误删的 main() 入口找回来。
|
||||||
|
|
||||||
|
事故经过:
|
||||||
|
swap_admin_page.py 把 relay.py 从 "_admin_page 那一行" 一直截到文件末尾,
|
||||||
|
但 _admin_page 之后**还有 main() 和 if __name__ == "__main__"**。
|
||||||
|
(我的扫描只找了 4 空格缩进的 " def ",没看见顶格的 "def main()" 和 "if __name__",
|
||||||
|
于是"到文件末尾"就把入口一起干掉了。)
|
||||||
|
结果:relay 一启动就退出,systemd 无限重启,所有接口 000。
|
||||||
|
|
||||||
|
教训:**"删到文件末尾"这种操作必须先确认末尾有什么。**
|
||||||
|
现在这一段单独摘出来恢复,并加了校验。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
RELAY = ROOT / "relay" / "relay.py"
|
||||||
|
GIT = r"C:\Program Files\Git\bin\git.exe"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
original = subprocess.run(
|
||||||
|
[GIT, "show", "HEAD:relay/relay.py"],
|
||||||
|
cwd=str(ROOT), capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace").stdout
|
||||||
|
|
||||||
|
if not original:
|
||||||
|
print(" [失败] 取不到 git 版本")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 从 "\ndef main()" 开始,到文件末尾
|
||||||
|
index = original.find("\ndef main()")
|
||||||
|
if index < 0:
|
||||||
|
# 试试别的名字
|
||||||
|
for name in ("\ndef serve()", "\ndef run()"):
|
||||||
|
index = original.find(name)
|
||||||
|
if index >= 0:
|
||||||
|
break
|
||||||
|
if index < 0:
|
||||||
|
print(" [失败] git 版本里也找不到 main()")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tail = original[index:]
|
||||||
|
print(" 从 git 里摘到 %d 字符的入口代码(%d 行)"
|
||||||
|
% (len(tail), tail.count("\n")))
|
||||||
|
|
||||||
|
current = RELAY.read_text(encoding="utf-8")
|
||||||
|
if "if __name__" in current:
|
||||||
|
print(" 现在已经有入口了,不用补")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
text = current.rstrip("\n") + "\n\n" + tail.lstrip("\n")
|
||||||
|
RELAY.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ast.parse(text)
|
||||||
|
print(" relay.py 语法 OK")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
print(" [失败] 行 %s: %s" % (exc.lineno, exc.msg))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
check = subprocess.run(
|
||||||
|
[sys.executable, "-c",
|
||||||
|
"import ast,sys;"
|
||||||
|
"tree=ast.parse(open(r'%s',encoding='utf-8').read());"
|
||||||
|
"names=[n.name for n in tree.body if isinstance(n,ast.FunctionDef)];"
|
||||||
|
"print(' 模块级函数:', names);"
|
||||||
|
"assert 'main' in names, 'main 不见了'"
|
||||||
|
% RELAY],
|
||||||
|
cwd=str(ROOT), capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
print((check.stdout or "").strip())
|
||||||
|
if check.returncode != 0:
|
||||||
|
print(" [失败] " + (check.stderr or "").strip()[-200:])
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(" ✓ main() 恢复了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把 swap_admin_page.py 误删的 4 个顶层定义补回来。
|
||||||
|
|
||||||
|
丢的(git 里还在):
|
||||||
|
_esc 2006 HTML 转义
|
||||||
|
_kb 2011 字节数格式化
|
||||||
|
_uptime 2019 运行时长格式化
|
||||||
|
Server 2034 ThreadingHTTPServer 子类 —— main() 要用它,
|
||||||
|
少了它就是 NameError,relay 起不来
|
||||||
|
|
||||||
|
做法:从 git 取下这一整段,去掉里面已经存在的部分(比如 main),
|
||||||
|
插到当前文件的 main() 前面。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
RELAY = ROOT / "relay" / "relay.py"
|
||||||
|
GIT = r"C:\Program Files\Git\bin\git.exe"
|
||||||
|
|
||||||
|
|
||||||
|
def top_defs(source: str) -> set:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
names = set()
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||||
|
ast.ClassDef)):
|
||||||
|
names.add(node.name)
|
||||||
|
elif isinstance(node, ast.Assign):
|
||||||
|
for target in node.targets:
|
||||||
|
if isinstance(target, ast.Name):
|
||||||
|
names.add(target.id)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
old = subprocess.run([GIT, "show", "HEAD:relay/relay.py"],
|
||||||
|
cwd=str(ROOT), capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace").stdout
|
||||||
|
cur = RELAY.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
have = top_defs(cur)
|
||||||
|
missing = {"_esc", "_kb", "_uptime", "Server"} - have
|
||||||
|
if not missing:
|
||||||
|
print(" 没有缺的了")
|
||||||
|
return 0
|
||||||
|
print(" 缺: %s" % ", ".join(sorted(missing)))
|
||||||
|
|
||||||
|
# 从 git 里按段摘出来
|
||||||
|
lines = old.splitlines()
|
||||||
|
blocks = []
|
||||||
|
index = 0
|
||||||
|
while index < len(lines):
|
||||||
|
line = lines[index]
|
||||||
|
match = re.match(r"^(def|class)\s+(\w+)", line)
|
||||||
|
if match and match.group(2) in missing:
|
||||||
|
# 收集这一段(直到下一个顶层定义或文件尾)
|
||||||
|
block = [line]
|
||||||
|
index += 1
|
||||||
|
while index < len(lines):
|
||||||
|
nxt = lines[index]
|
||||||
|
if re.match(r"^(def|class)\s+\w+", nxt) or nxt.startswith("if __name__"):
|
||||||
|
break
|
||||||
|
block.append(nxt)
|
||||||
|
index += 1
|
||||||
|
# 去掉尾部多余空行
|
||||||
|
while block and not block[-1].strip():
|
||||||
|
block.pop()
|
||||||
|
blocks.append("\n".join(block))
|
||||||
|
print(" 摘到 %s(%d 行)" % (match.group(2), len(block)))
|
||||||
|
continue
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
if not blocks:
|
||||||
|
print(" [失败] 一段都没摘到")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 插到 main() 前面
|
||||||
|
marker = "\ndef main("
|
||||||
|
position = cur.find(marker)
|
||||||
|
if position < 0:
|
||||||
|
print(" [失败] 找不到 main()")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
header = ("\n\n# ======================================================================\n"
|
||||||
|
"# 下面这几个是页面/启动辅助(重做管理页时被误删,从 git 恢复)\n"
|
||||||
|
"# ======================================================================\n\n")
|
||||||
|
text = (cur[:position + 1] + header + "\n\n".join(blocks) + "\n"
|
||||||
|
+ cur[position:])
|
||||||
|
|
||||||
|
RELAY.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ast.parse(text)
|
||||||
|
print(" relay.py 语法 OK")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
print(" [失败] 行 %s: %s" % (exc.lineno, exc.msg))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
now = top_defs(text)
|
||||||
|
still = missing - now
|
||||||
|
if still:
|
||||||
|
print(" [失败] 还缺: %s" % ", ".join(sorted(still)))
|
||||||
|
return 1
|
||||||
|
print(" ✓ 4 个都补回来了")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 复核(关键定义是否都在):")
|
||||||
|
for name in ("_esc", "_kb", "_uptime", "Server", "Handler", "main",
|
||||||
|
"admin_key", "public_url", "_list_downloads"):
|
||||||
|
print(" %-18s %s" % (name, "有" if name in now else "**缺**"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''拍电脑端主窗口截图(官网上用)。
|
||||||
|
|
||||||
|
踩过的坑:
|
||||||
|
传 --code 启动是**一次性命令**,设完码就退出,窗口根本不会出现。
|
||||||
|
必须先单独跑一次 --code,再不带参数启动,才有主窗口。
|
||||||
|
|
||||||
|
窗口标题现在是 "ykxmz · 第一次设置"(设过码之后变成控制窗口)。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from ctypes import wintypes
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
SHOTS = ROOT / "_build" / "site_shots"
|
||||||
|
EXE = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe"
|
||||||
|
DATA = ROOT / "_build" / "_shots_data"
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
gdi32 = ctypes.windll.gdi32
|
||||||
|
|
||||||
|
|
||||||
|
def windows_of(pid: int) -> list:
|
||||||
|
out = []
|
||||||
|
|
||||||
|
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||||||
|
def cb(hwnd, _lp):
|
||||||
|
wpid = wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(wpid))
|
||||||
|
if wpid.value == pid:
|
||||||
|
rect = wintypes.RECT()
|
||||||
|
user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||||
|
if user32.IsWindowVisible(hwnd) and (rect.right - rect.left) > 200:
|
||||||
|
out.append(hwnd)
|
||||||
|
return True
|
||||||
|
|
||||||
|
user32.EnumWindows(cb, 0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def capture(hwnd: int, path: Path) -> bool:
|
||||||
|
rect = wintypes.RECT()
|
||||||
|
user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||||
|
width = rect.right - rect.left
|
||||||
|
height = rect.bottom - rect.top
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
hdc_window = user32.GetWindowDC(hwnd)
|
||||||
|
hdc_mem = gdi32.CreateCompatibleDC(hdc_window)
|
||||||
|
bitmap = gdi32.CreateCompatibleBitmap(hdc_window, width, height)
|
||||||
|
gdi32.SelectObject(hdc_mem, bitmap)
|
||||||
|
user32.PrintWindow(hwnd, hdc_mem, 2) # 2 = PW_RENDERFULLCONTENT
|
||||||
|
|
||||||
|
class BMIH(ctypes.Structure):
|
||||||
|
_fields_ = [("biSize", wintypes.DWORD), ("biWidth", wintypes.LONG),
|
||||||
|
("biHeight", wintypes.LONG), ("biPlanes", wintypes.WORD),
|
||||||
|
("biBitCount", wintypes.WORD),
|
||||||
|
("biCompression", wintypes.DWORD),
|
||||||
|
("biSizeImage", wintypes.DWORD),
|
||||||
|
("biXPelsPerMeter", wintypes.LONG),
|
||||||
|
("biYPelsPerMeter", wintypes.LONG),
|
||||||
|
("biClrUsed", wintypes.DWORD),
|
||||||
|
("biClrImportant", wintypes.DWORD)]
|
||||||
|
|
||||||
|
header = BMIH()
|
||||||
|
header.biSize = ctypes.sizeof(BMIH)
|
||||||
|
header.biWidth = width
|
||||||
|
header.biHeight = -height
|
||||||
|
header.biPlanes = 1
|
||||||
|
header.biBitCount = 32
|
||||||
|
|
||||||
|
buf = ctypes.create_string_buffer(width * height * 4)
|
||||||
|
gdi32.GetDIBits(hdc_mem, bitmap, 0, height, buf, ctypes.byref(header), 0)
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.frombuffer("RGBA", (width, height), buf, "raw", "BGRA", 0, 1)
|
||||||
|
img.convert("RGB").save(path)
|
||||||
|
|
||||||
|
gdi32.DeleteObject(bitmap)
|
||||||
|
gdi32.DeleteDC(hdc_mem)
|
||||||
|
user32.ReleaseDC(hwnd, hdc_window)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
SHOTS.mkdir(parents=True, exist_ok=True)
|
||||||
|
DATA.mkdir(parents=True, exist_ok=True)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["YKXMZ_DATA"] = str(DATA)
|
||||||
|
|
||||||
|
# 先清掉旧进程
|
||||||
|
subprocess.run(["powershell", "-NoProfile", "-Command",
|
||||||
|
"Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||||
|
"Stop-Process -Force"],
|
||||||
|
capture_output=True, timeout=60)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
print(" 一、清掉旧数据,模拟全新安装(这样才会出设置窗口)")
|
||||||
|
import shutil
|
||||||
|
# 设过配对码之后被控端就完全无窗口运行了(这是设计:
|
||||||
|
# "用户要的是全程无窗口")。所以想拍界面必须清掉数据当首次运行。
|
||||||
|
shutil.rmtree(DATA, ignore_errors=True)
|
||||||
|
DATA.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(" 清好了")
|
||||||
|
|
||||||
|
print(" 二、启动(首次运行 -> 出「第一次设置」窗口)")
|
||||||
|
proc = subprocess.Popen([str(EXE), "--port", "8898"],
|
||||||
|
cwd=str(ROOT), env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL)
|
||||||
|
time.sleep(14)
|
||||||
|
|
||||||
|
handles = windows_of(proc.pid)
|
||||||
|
print(" 找到 %d 个可见窗口" % len(handles))
|
||||||
|
if not handles:
|
||||||
|
print(" [失败] 没有窗口")
|
||||||
|
proc.terminate()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
hwnd = handles[0]
|
||||||
|
user32.SetForegroundWindow(hwnd)
|
||||||
|
user32.MoveWindow(hwnd, 80, 80, 520, 600, True)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
out = SHOTS / "pc-main.png"
|
||||||
|
if capture(hwnd, out):
|
||||||
|
print(" ✓ %s %.1f KB" % (out.name, out.stat().st_size / 1024))
|
||||||
|
# 顺便看看标题
|
||||||
|
length = user32.GetWindowTextLengthW(hwnd)
|
||||||
|
buf = ctypes.create_unicode_buffer(length + 1)
|
||||||
|
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||||
|
print(" 窗口标题: %r" % buf.value)
|
||||||
|
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
subprocess.run(["powershell", "-NoProfile", "-Command",
|
||||||
|
"Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||||
|
"Stop-Process -Force"],
|
||||||
|
capture_output=True, timeout=60)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 产物:")
|
||||||
|
for p in sorted(SHOTS.glob("*.png")):
|
||||||
|
print(" %-24s %6.1f KB" % (p.name, p.stat().st_size / 1024))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''重新拍电脑端窗口截图(旧图还写着改名前的"局域网遥控 被控端")。
|
||||||
|
|
||||||
|
顺便拍几张不同状态的,官网上能展示"这东西长什么样"。
|
||||||
|
|
||||||
|
做法:用 --code 启动(非 --silent,所以会显示窗口),
|
||||||
|
然后用 Win32 的 PrintWindow 把窗口内容抓下来。
|
||||||
|
不用截整个屏幕 —— 那样会把桌面上的别的东西也拍进去。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from ctypes import wintypes
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
SHOTS = ROOT / "_build" / "site_shots"
|
||||||
|
EXE = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe"
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
gdi32 = ctypes.windll.gdi32
|
||||||
|
|
||||||
|
|
||||||
|
def find_window(title_part: str) -> int:
|
||||||
|
"""按标题片段找窗口句柄。"""
|
||||||
|
found = []
|
||||||
|
|
||||||
|
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||||||
|
def callback(hwnd, _lparam):
|
||||||
|
length = user32.GetWindowTextLengthW(hwnd)
|
||||||
|
if length:
|
||||||
|
buf = ctypes.create_unicode_buffer(length + 1)
|
||||||
|
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||||
|
if title_part in buf.value and user32.IsWindowVisible(hwnd):
|
||||||
|
found.append((hwnd, buf.value))
|
||||||
|
return True
|
||||||
|
|
||||||
|
user32.EnumWindows(callback, 0)
|
||||||
|
return found[0] if found else (0, "")
|
||||||
|
|
||||||
|
|
||||||
|
def capture(hwnd: int, path: Path) -> bool:
|
||||||
|
"""用 PrintWindow 抓窗口内容(比截屏干净,不带桌面背景)。"""
|
||||||
|
rect = wintypes.RECT()
|
||||||
|
user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||||
|
width = rect.right - rect.left
|
||||||
|
height = rect.bottom - rect.top
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
hdc_window = user32.GetWindowDC(hwnd)
|
||||||
|
hdc_mem = gdi32.CreateCompatibleDC(hdc_window)
|
||||||
|
bitmap = gdi32.CreateCompatibleBitmap(hdc_window, width, height)
|
||||||
|
gdi32.SelectObject(hdc_mem, bitmap)
|
||||||
|
|
||||||
|
# 2 = PW_RENDERFULLCONTENT,win10+ 抓 DWM 合成内容必须用这个
|
||||||
|
ok = user32.PrintWindow(hwnd, hdc_mem, 2)
|
||||||
|
|
||||||
|
class BITMAPINFOHEADER(ctypes.Structure):
|
||||||
|
_fields_ = [("biSize", wintypes.DWORD),
|
||||||
|
("biWidth", wintypes.LONG),
|
||||||
|
("biHeight", wintypes.LONG),
|
||||||
|
("biPlanes", wintypes.WORD),
|
||||||
|
("biBitCount", wintypes.WORD),
|
||||||
|
("biCompression", wintypes.DWORD),
|
||||||
|
("biSizeImage", wintypes.DWORD),
|
||||||
|
("biXPelsPerMeter", wintypes.LONG),
|
||||||
|
("biYPelsPerMeter", wintypes.LONG),
|
||||||
|
("biClrUsed", wintypes.DWORD),
|
||||||
|
("biClrImportant", wintypes.DWORD)]
|
||||||
|
|
||||||
|
header = BITMAPINFOHEADER()
|
||||||
|
header.biSize = ctypes.sizeof(BITMAPINFOHEADER)
|
||||||
|
header.biWidth = width
|
||||||
|
header.biHeight = -height # 负数 = 自上而下
|
||||||
|
header.biPlanes = 1
|
||||||
|
header.biBitCount = 32
|
||||||
|
header.biCompression = 0
|
||||||
|
|
||||||
|
buffer_size = width * height * 4
|
||||||
|
buffer = ctypes.create_string_buffer(buffer_size)
|
||||||
|
gdi32.GetDIBits(hdc_mem, bitmap, 0, height, buffer, ctypes.byref(header), 0)
|
||||||
|
|
||||||
|
# 用 Pillow 存(项目里本来就有)
|
||||||
|
from PIL import Image
|
||||||
|
image = Image.frombuffer("RGBA", (width, height), buffer,
|
||||||
|
"raw", "BGRA", 0, 1)
|
||||||
|
image.convert("RGB").save(path)
|
||||||
|
|
||||||
|
gdi32.DeleteObject(bitmap)
|
||||||
|
gdi32.DeleteDC(hdc_mem)
|
||||||
|
user32.ReleaseDC(hwnd, hdc_window)
|
||||||
|
return bool(ok)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
SHOTS.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.environ["YKXMZ_DATA"] = str(ROOT / "_build" / "_shots_data")
|
||||||
|
Path(os.environ["YKXMZ_DATA"]).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(" 启动被控端(会显示窗口)")
|
||||||
|
env = dict(os.environ)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[str(EXE), "--code", "789113", "--port", "8899", "--name", "教室电脑"],
|
||||||
|
cwd=str(ROOT), env=env,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
time.sleep(9)
|
||||||
|
|
||||||
|
hwnd, title = find_window("ykxmz")
|
||||||
|
if not hwnd:
|
||||||
|
print(" [失败] 没找到窗口")
|
||||||
|
proc.terminate()
|
||||||
|
return 1
|
||||||
|
print(" 找到窗口: %r" % title)
|
||||||
|
|
||||||
|
# 拉到屏幕内可见位置,保证 PrintWindow 能拿到内容
|
||||||
|
user32.SetForegroundWindow(hwnd)
|
||||||
|
user32.MoveWindow(hwnd, 60, 60, 420, 460, True)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
out = SHOTS / "pc-main.png"
|
||||||
|
if capture(hwnd, out):
|
||||||
|
print(" ✓ 拍了 %s %.1f KB" % (out.name, out.stat().st_size / 1024))
|
||||||
|
else:
|
||||||
|
print(" [失败] 抓取失败")
|
||||||
|
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=8)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 产物:")
|
||||||
|
for p in sorted(SHOTS.glob("*.png")):
|
||||||
|
print(" %-24s %6.1f KB" % (p.name, p.stat().st_size / 1024))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -115,6 +115,68 @@ footer{padding:52px 0 64px;border-top:1px solid #e6e8eb;
|
|||||||
.foot-links{display:flex;gap:22px}
|
.foot-links{display:flex;gap:22px}
|
||||||
.foot-links a:hover{color:#1a1d21}
|
.foot-links a:hover{color:#1a1d21}
|
||||||
.copy{color:#9ca3af;font-size:13px;margin-top:26px;text-align:center}
|
.copy{color:#9ca3af;font-size:13px;margin-top:26px;text-align:center}
|
||||||
|
|
||||||
|
/* ---------- 产品展示 ---------- */
|
||||||
|
.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:#F5F7FA;border-radius:26px;overflow:hidden;
|
||||||
|
font-size:11px;color:#1F2328}
|
||||||
|
.w-status{display:flex;justify-content:space-between;align-items:center;
|
||||||
|
padding:11px 13px 7px;font-size:10px;font-weight:600}
|
||||||
|
.w-conn{color:#16a34a;font-weight:600}
|
||||||
|
.w-tabs{display:flex;gap:4px;overflow:hidden;padding:0 11px 8px;
|
||||||
|
border-bottom:1px solid #E3E7ED}
|
||||||
|
.w-tab{flex:none;padding:3px 8px;border-radius:999px;color:#656D76;
|
||||||
|
font-size:10px;white-space:nowrap}
|
||||||
|
.w-tab.on{background:#185CE8;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:#FFFFFF;border:1px solid #E3E7ED;
|
||||||
|
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:#1F2328;white-space:nowrap}
|
||||||
|
.w-foot{padding:9px 13px 12px;color:#656D76;font-size:9px;
|
||||||
|
border-top:1px solid #E3E7ED}
|
||||||
|
|
||||||
|
/* 电脑端截图 */
|
||||||
|
.pcshot{margin:0;text-align:center}
|
||||||
|
.pcshot img{width:100%;max-width:360px;height:auto;
|
||||||
|
border:1px solid #E3E7ED;border-radius:12px;
|
||||||
|
box-shadow:0 18px 40px rgba(16,24,40,.14)}
|
||||||
|
.pcshot figcaption{margin-top:14px;color:#656D76;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:4px;
|
||||||
|
font-size:22px;flex:none}
|
||||||
|
.node span{font-size:11px;color:#6b7280}
|
||||||
|
.node.relay{font-size:19px}
|
||||||
|
.link{flex:1;min-width:16px;position:relative;display:flex;
|
||||||
|
align-items:center;justify-content:center}
|
||||||
|
.link .line{width:100%;height:2px;background:#d1d5db;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}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -159,6 +221,52 @@ footer{padding:52px 0 64px;border-top:1px solid #e6e8eb;
|
|||||||
<div class="steps"><div class="step"><div class="step-num">1</div><div class="step-title">电脑端</div><div class="step-desc">在教室那台 Windows 电脑上运行安装包,填一个 6 位配对码,装完自动后台运行</div></div><div class="step"><div class="step-num">2</div><div class="step-title">手表端</div><div class="step-desc">在安卓手表上装 APK,首页点「局域网连接」或「输入配对码连接」</div></div><div class="step"><div class="step-num">3</div><div class="step-title">连上就用</div><div class="step-desc">配对一次之后,以后打开就自动连,不用再输数字</div></div></div>
|
<div class="steps"><div class="step"><div class="step-num">1</div><div class="step-title">电脑端</div><div class="step-desc">在教室那台 Windows 电脑上运行安装包,填一个 6 位配对码,装完自动后台运行</div></div><div class="step"><div class="step-num">2</div><div class="step-title">手表端</div><div class="step-desc">在安卓手表上装 APK,首页点「局域网连接」或「输入配对码连接」</div></div><div class="step"><div class="step-num">3</div><div class="step-title">连上就用</div><div class="step-desc">配对一次之后,以后打开就自动连,不用再输数字</div></div></div>
|
||||||
</div></section>
|
</div></section>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="shots"><div class="wrap">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2>长什么样</h2>
|
||||||
|
<p>手表上十个分页,常用操作都在第一屏</p>
|
||||||
|
</div>
|
||||||
|
<div class="showcase">
|
||||||
|
<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"><span class="w-tab on">媒体</span><span class="w-tab">屏幕</span><span class="w-tab">文件</span><span class="w-tab">语音</span><span class="w-tab">电脑</span><span class="w-tab">网络</span><span class="w-tab">系统</span><span class="w-tab">教室</span><span class="w-tab">效率</span><span class="w-tab">娱乐</span></div><div class="w-body"><div class="w-head">媒体</div><div class="w-grid"><div class="w-btn"><span class="w-ic">🔊</span><span class="w-lb">放音频</span></div><div class="w-btn"><span class="w-ic">▶</span><span class="w-lb">放视频</span></div><div class="w-btn"><span class="w-ic">⏸</span><span class="w-lb">暂停</span></div><div class="w-btn"><span class="w-ic">⏵</span><span class="w-lb">继续</span></div><div class="w-btn"><span class="w-ic">+</span><span class="w-lb">音量 +</span></div><div class="w-btn"><span class="w-ic">−</span><span class="w-lb">音量 −</span></div><div class="w-btn"><span class="w-ic">🔇</span><span class="w-lb">静音</span></div><div class="w-btn"><span class="w-ic">⏭</span><span class="w-lb">下一首</span></div><div class="w-btn"><span class="w-ic">🖼</span><span class="w-lb">投图到屏幕</span></div></div></div><div class="w-foot">被控端:教室电脑 · 192.168.2.64</div></div></div>
|
||||||
|
<figure class="pcshot"><img src="/shot/pc-main.png" alt="ykxmz 电脑端首次设置界面" loading="lazy"><figcaption>电脑端:装完填个 6 位配对码就行</figcaption></figure>
|
||||||
|
</div>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<section id="how-connect"><div class="wrap">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2>怎么连上的</h2>
|
||||||
|
<p>同一个 WiFi 就走局域网直连,不同网络自动切中转</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
|
||||||
<section id="download"><div class="wrap">
|
<section id="download"><div class="wrap">
|
||||||
<div class="sec-head">
|
<div class="sec-head">
|
||||||
<h2>下载</h2>
|
<h2>下载</h2>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,67 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把 relay.py 里那一大坨 _admin_page 换成调用 admin 模块。
|
||||||
|
|
||||||
|
原来的 _admin_page 从 1489 行一直到文件末尾(600 行内联 HTML+CSS+JS),
|
||||||
|
全塞在一个 f-string 里,改个颜色要在一堆 {{ }} 转义里找。
|
||||||
|
现在只留一个薄封装,真正的页面在 relay/admin.py。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
RELAY = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
/ "relay" / "relay.py")
|
||||||
|
|
||||||
|
NEW_METHOD = ''' def _admin_page(self) -> None:
|
||||||
|
"""管理后台。
|
||||||
|
|
||||||
|
实际渲染在 relay/admin.py —— 原来几百行 HTML/CSS/JS
|
||||||
|
内联在这里的 f-string 里,改一处样式要在一堆转义花括号里翻,
|
||||||
|
没法维护。拆出去之后这里只负责把数据递过去。
|
||||||
|
"""
|
||||||
|
from admin import render_admin
|
||||||
|
snap = RELAY.snapshot()
|
||||||
|
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"))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
lines = RELAY.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||||
|
|
||||||
|
# 找到 _admin_page 那一行
|
||||||
|
start = None
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line.startswith(" def _admin_page(self)"):
|
||||||
|
start = index
|
||||||
|
break
|
||||||
|
if start is None:
|
||||||
|
print(" [失败] 找不到 _admin_page")
|
||||||
|
return 1
|
||||||
|
print(" _admin_page 在第 %d 行,到文件末尾(%d 行)"
|
||||||
|
% (start + 1, len(lines)))
|
||||||
|
|
||||||
|
new_lines = lines[:start] + [NEW_METHOD]
|
||||||
|
text = "".join(new_lines)
|
||||||
|
RELAY.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ast.parse(text)
|
||||||
|
print(" relay.py 语法 OK")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
print(" [失败] 行 %s: %s" % (exc.lineno, exc.msg))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(" 文件从 %d 行缩到 %d 行" % (len(lines), len(new_lines)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 验收新版管理后台
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=http://127.0.0.1:8080
|
||||||
|
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||||
|
|
||||||
|
curl -s -H "X-Admin: $KEY" --max-time 20 "$BASE/zhongzhuanzhan" -o /tmp/a.html
|
||||||
|
|
||||||
|
echo " 管理页 HTTP $(curl -s -o /dev/null -w '%{http_code}' -H "X-Admin: $KEY" --max-time 15 "$BASE/zhongzhuanzhan") $(wc -c < /tmp/a.html) 字节"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 新版设计元素:"
|
||||||
|
has() { if grep -q "$1" /tmp/a.html; then echo " ✓ $2"; else echo " ✗ $2"; fi; }
|
||||||
|
has 'class="top"' '吸顶顶栏'
|
||||||
|
has 'class="hero"' '公网地址区块'
|
||||||
|
has 'class="nums"' '数字卡'
|
||||||
|
has 'class="card"' '设备表卡片'
|
||||||
|
has 'class="tool"' '工具卡'
|
||||||
|
has 'class="pill on"' '在线徽章'
|
||||||
|
has 'id="pickAll"' '全选'
|
||||||
|
has 'id="autoRef"' '自动刷新'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 功能接口(一个都不能坏):"
|
||||||
|
for api in "/v1/health" "/v1/downloads" "/v1/admin/netcheck"; do
|
||||||
|
printf " %-24s HTTP %s\n" "$api" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -H "X-Admin: $KEY" --max-time 12 "$BASE$api")"
|
||||||
|
done
|
||||||
|
printf " %-24s HTTP %s\n" "删除单台(空id,应400)" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "X-Admin: $KEY" \
|
||||||
|
-H 'Content-Type: application/json' -d '{}' --max-time 12 "$BASE/v1/admin/delete")"
|
||||||
|
printf " %-24s HTTP %s\n" "批量删除(空数组)" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "X-Admin: $KEY" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"deviceIds":[]}' --max-time 12 "$BASE/v1/admin/delete_many")"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 其它页面没受影响:"
|
||||||
|
echo " 官网 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/")"
|
||||||
|
echo " 官网大小 $(curl -s --max-time 15 "$BASE/" | wc -c) 字节"
|
||||||
|
echo " 产品截图 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/shot/pc-main.png")"
|
||||||
|
echo " favicon HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/favicon.ico")"
|
||||||
|
echo " /admin 跳转 $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/admin")"
|
||||||
|
echo " 未登录 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/zhongzhuanzhan")"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 公网:"
|
||||||
|
for i in 1 2 3; do
|
||||||
|
printf " %d: HTTP %s\n" "$i" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " relay 有没有报错:"
|
||||||
|
journalctl -u lanremote-relay --since '2 minutes ago' --no-pager 2>/dev/null \
|
||||||
|
| grep -iE 'error|traceback|exception' | tail -5 | sed 's/^/ /'
|
||||||
|
echo " (没输出=正常)"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 带登录验证新版管理后台
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=http://127.0.0.1:8080
|
||||||
|
PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null)
|
||||||
|
|
||||||
|
# 用 cookie 登录(比 X-Admin 头更接近真实使用)
|
||||||
|
curl -s -c /tmp/ck.txt -o /dev/null --max-time 15 \
|
||||||
|
-d "username=xmz7891&password=$PASS" "$BASE/admin/login"
|
||||||
|
|
||||||
|
curl -s -b /tmp/ck.txt --max-time 20 "$BASE/zhongzhuanzhan" -o /tmp/a.html
|
||||||
|
size=$(wc -c < /tmp/a.html)
|
||||||
|
code=$(curl -s -b /tmp/ck.txt -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/zhongzhuanzhan")
|
||||||
|
echo " 管理页 HTTP $code $size 字节"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 新版设计元素:"
|
||||||
|
has() { if grep -q "$1" /tmp/a.html; then echo " ✓ $2"; else echo " ✗ $2"; fi; }
|
||||||
|
has 'class="top"' '吸顶顶栏'
|
||||||
|
has 'class="state"' '运行中徽章'
|
||||||
|
has 'class="hero"' '公网地址区块'
|
||||||
|
has 'id="urlBox"' '地址框'
|
||||||
|
has 'id="copyUrl"' '复制按钮'
|
||||||
|
has 'class="nums"' '数字卡'
|
||||||
|
has 'class="num"' '数字卡元素'
|
||||||
|
has 'class="card"' '设备表卡片'
|
||||||
|
has 'class="tool"' '工具卡(体检+下载)'
|
||||||
|
has 'id="pickAll"' '全选'
|
||||||
|
has 'id="delPicked"' '删除选中'
|
||||||
|
has 'id="delAll"' '删除全部'
|
||||||
|
has 'id="netBtn"' '体检按钮'
|
||||||
|
has 'id="autoRef"' '自动刷新'
|
||||||
|
has 'class="pill' '状态徽章'
|
||||||
|
has 'id="toast"' '提示条'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 计数:"
|
||||||
|
printf " 数字卡 %s 个(应 4)\n" "$(grep -o 'class="num"' /tmp/a.html | wc -l)"
|
||||||
|
printf " 工具卡 %s 个(应 2)\n" "$(grep -o 'class="tool"' /tmp/a.html | wc -l)"
|
||||||
|
printf " 表格列 %s 个(应 8)\n" "$(grep -o '<th' /tmp/a.html | wc -l)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 功能接口:"
|
||||||
|
for api in "/v1/health" "/v1/downloads" "/v1/admin/netcheck"; do
|
||||||
|
printf " %-24s HTTP %s\n" "$api" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/ck.txt --max-time 12 "$BASE$api")"
|
||||||
|
done
|
||||||
|
printf " %-24s HTTP %s\n" "删除(空id, 应 400)" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/ck.txt -X POST \
|
||||||
|
-H 'Content-Type: application/json' -d '{}' --max-time 12 "$BASE/v1/admin/delete")"
|
||||||
|
printf " %-24s HTTP %s\n" "批量删除(空数组)" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/ck.txt -X POST \
|
||||||
|
-H 'Content-Type: application/json' -d '{"deviceIds":[]}' --max-time 12 "$BASE/v1/admin/delete_many")"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 其它页面:"
|
||||||
|
echo " 官网 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/")"
|
||||||
|
echo " 官网大小 $(curl -s --max-time 15 "$BASE/" | wc -c) 字节"
|
||||||
|
echo " 产品截图 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/shot/pc-main.png")"
|
||||||
|
echo " favicon HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/favicon.ico")"
|
||||||
|
echo " /admin 跳转 $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/admin")"
|
||||||
|
echo " 未登录(应登录页) HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/zhongzhuanzhan")"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " relay 有没有报错:"
|
||||||
|
journalctl -u lanremote-relay --since '2 minutes ago' --no-pager 2>/dev/null \
|
||||||
|
| grep -icE 'traceback|nameerror|error' | sed 's/^/ 错误行数: /'
|
||||||
|
tail -5 /root/lanremote-relay/relay.log 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 公网:"
|
||||||
|
for i in 1 2 3; do
|
||||||
|
printf " %d: HTTP %s\n" "$i" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/)"
|
||||||
|
done
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 验收:产品展示区块 + 截图路由 + 原有功能没被搞坏
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=http://127.0.0.1:8080
|
||||||
|
PUB=https://relay.xxmxxs.top
|
||||||
|
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||||
|
|
||||||
|
curl -s --max-time 15 "$BASE/" -o /tmp/s.html
|
||||||
|
size=$(wc -c < /tmp/s.html)
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/")
|
||||||
|
echo " 官网 HTTP $code $size 字节"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 新增区块:"
|
||||||
|
has() { if grep -q "$1" /tmp/s.html; then echo " ✓ $2"; else echo " ✗ $2"; fi; }
|
||||||
|
has 'id="shots"' '产品展示(长什么样)'
|
||||||
|
has 'class="watch"' '手表界面 mockup'
|
||||||
|
has 'w-tab' '手表标签条'
|
||||||
|
has '/shot/pc-main.png' '电脑端真实截图'
|
||||||
|
has 'id="how-connect"' '怎么连上的'
|
||||||
|
has 'class="flow"' '连接方式示意'
|
||||||
|
has '局域网 3 ms' '局域网延迟标注'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 区块计数:"
|
||||||
|
printf " 手表标签 %s 个(应为 10)\n" "$(grep -o 'w-tab' /tmp/s.html | wc -l)"
|
||||||
|
printf " 手表按钮 %s 个(应为 9)\n" "$(grep -o 'class="w-btn"' /tmp/s.html | wc -l)"
|
||||||
|
printf " 连接示意 %s 列(应为 2)\n" "$(grep -o 'class="flow-col"' /tmp/s.html | wc -l)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 原有区块还在吗:"
|
||||||
|
for pair in "吸顶导航:nav-in" "Hero:hero" "功能网格:id=\"features\"" \
|
||||||
|
"三步上手:id=\"how\"" "下载区:id=\"download\"" "FAQ:id=\"faq\""; do
|
||||||
|
has "${pair#*:}" "${pair%%:*}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 截图路由:"
|
||||||
|
echo " /shot/pc-main.png HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/shot/pc-main.png") $(curl -s -o /dev/null -w '%{size_download}' --max-time 15 "$BASE/shot/pc-main.png") 字节"
|
||||||
|
echo " 路径穿越 /shot/../relay.py HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/shot/../relay.py")"
|
||||||
|
echo " 非图片 /shot/x.txt HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/shot/x.txt")"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 其它功能:"
|
||||||
|
echo " /v1/health HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/v1/health")"
|
||||||
|
echo " /zhongzhuanzhan HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/zhongzhuanzhan")"
|
||||||
|
echo " /v1/downloads HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/v1/downloads")"
|
||||||
|
echo " health 大小 $(curl -s --max-time 10 "$BASE/v1/health" | wc -c) 字节"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 公网:"
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
printf " %d: HTTP %s\n" "$i" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H 'User-Agent: LanRemote/1.4.0' "$PUB/")"
|
||||||
|
done
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把产品展示区块接进官网,并把截图传上去。'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
LANDING = ROOT / "relay" / "landing.py"
|
||||||
|
RELAY = ROOT / "relay" / "relay.py"
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
|
||||||
|
from devssh import run, upload # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def wire_landing() -> None:
|
||||||
|
text = LANDING.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if "showcase" in text:
|
||||||
|
print(" landing.py 已经接过 showcase")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1) 引入
|
||||||
|
text = text.replace(
|
||||||
|
"from urllib.parse import quote",
|
||||||
|
"from urllib.parse import quote\n\n"
|
||||||
|
"import showcase", 1)
|
||||||
|
|
||||||
|
# 2) CSS 拼进去
|
||||||
|
marker = '""" % {"BG": BG, "SURFACE": SURFACE, "BORDER": BORDER, "TEXT": TEXT,'
|
||||||
|
if marker in text:
|
||||||
|
text = text.replace(
|
||||||
|
marker,
|
||||||
|
'""" + showcase.SHOWCASE_CSS % {"BG": BG, "SURFACE": SURFACE,\n'
|
||||||
|
' "BORDER": BORDER, "TEXT": TEXT,', 1)
|
||||||
|
print(" CSS 已拼接")
|
||||||
|
else:
|
||||||
|
print(" [警告] CSS 锚点没找到,跳过")
|
||||||
|
|
||||||
|
# 3) HTML 区块插到"下载"之前
|
||||||
|
anchor = '<section id="download">'
|
||||||
|
if anchor in text:
|
||||||
|
text = text.replace(
|
||||||
|
anchor,
|
||||||
|
'%(showcase)s\n\n' + anchor, 1)
|
||||||
|
# 4) 渲染参数里加上
|
||||||
|
text = text.replace(
|
||||||
|
' "dls": _download_cards(files),',
|
||||||
|
' "showcase": showcase.render_showcase(),\n'
|
||||||
|
' "dls": _download_cards(files),', 1)
|
||||||
|
print(" HTML 区块已插入")
|
||||||
|
else:
|
||||||
|
print(" [警告] download 区块锚点没找到")
|
||||||
|
|
||||||
|
LANDING.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
try:
|
||||||
|
ast.parse(text)
|
||||||
|
print(" landing.py 语法 OK")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
print(" [失败] 语法错误 行 %s: %s" % (exc.lineno, exc.msg))
|
||||||
|
lines = text.splitlines()
|
||||||
|
for i in range(max(0, (exc.lineno or 1) - 3),
|
||||||
|
min(len(lines), (exc.lineno or 1) + 2)):
|
||||||
|
print(" %d: %s" % (i + 1, lines[i][:92]))
|
||||||
|
|
||||||
|
|
||||||
|
def wire_relay_route() -> None:
|
||||||
|
"""加一条 /shot/<name> 路由,把截图发出去(白名单后缀)。"""
|
||||||
|
text = RELAY.read_text(encoding="utf-8")
|
||||||
|
if "/shot/" in text:
|
||||||
|
print(" relay.py 已经有 /shot/ 路由")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 找下载路由附近插入
|
||||||
|
anchor = 'if path.startswith("/download/"):'
|
||||||
|
if anchor not in text:
|
||||||
|
print(" [警告] 找不到 /download/ 路由锚点,手工看下")
|
||||||
|
return
|
||||||
|
|
||||||
|
block = ''' # 官网用的产品截图。
|
||||||
|
# 单独一条路由(不放进下载区),只允许 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
|
||||||
|
|
||||||
|
'''
|
||||||
|
text = text.replace(" " + anchor, block + " " + anchor, 1)
|
||||||
|
RELAY.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
try:
|
||||||
|
ast.parse(text)
|
||||||
|
print(" relay.py 语法 OK")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
print(" [失败] relay.py 语法错误 行 %s: %s" % (exc.lineno, exc.msg))
|
||||||
|
|
||||||
|
|
||||||
|
def deploy() -> None:
|
||||||
|
print()
|
||||||
|
print(" 部署")
|
||||||
|
run("mkdir -p /root/lanremote-relay/site_shots", timeout=60)
|
||||||
|
upload(str(ROOT / "_build" / "site_shots" / "pc-main.png"),
|
||||||
|
"/root/lanremote-relay/site_shots/pc-main.png", 0o644)
|
||||||
|
print(" 截图已上传")
|
||||||
|
upload(str(LANDING), "/root/lanremote-relay/landing.py", 0o644)
|
||||||
|
upload(str(ROOT / "relay" / "showcase.py"),
|
||||||
|
"/root/lanremote-relay/showcase.py", 0o644)
|
||||||
|
upload(str(RELAY), "/root/lanremote-relay/relay.py", 0o755)
|
||||||
|
print(" 代码已上传")
|
||||||
|
run("systemctl restart lanremote-relay", timeout=90)
|
||||||
|
out = run("sleep 6; curl -s -o /dev/null -w 'HTTP %{http_code} %{size_download}\\n' "
|
||||||
|
"http://127.0.0.1:8080/; "
|
||||||
|
"curl -s -o /dev/null -w '截图 HTTP %{http_code} %{size_download}\\n' "
|
||||||
|
"http://127.0.0.1:8080/shot/pc-main.png", timeout=120)[1]
|
||||||
|
print(" " + out.replace("\n", "\n "))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
wire_landing()
|
||||||
|
wire_relay_route()
|
||||||
|
deploy()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+640
@@ -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("&", "&").replace("<", "<")
|
||||||
|
.replace(">", ">").replace('"', """))
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import showcase
|
||||||
|
|
||||||
# ---------------- 设计令牌 ----------------
|
# ---------------- 设计令牌 ----------------
|
||||||
BG = "#ffffff"
|
BG = "#ffffff"
|
||||||
SURFACE = "#fafbfc"
|
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,
|
""" % {"BG": BG, "SURFACE": SURFACE, "BORDER": BORDER, "TEXT": TEXT,
|
||||||
"MUTED": MUTED, "FAINT": FAINT, "ACCENT": ACCENT}
|
"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:
|
def render_site(files: list) -> str:
|
||||||
"""渲染官网首页。files 是 _list_downloads() 的结果。"""
|
"""渲染官网首页。files 是 _list_downloads() 的结果。"""
|
||||||
@@ -359,6 +368,8 @@ def render_site(files: list) -> str:
|
|||||||
</a>
|
</a>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="#features">功能</a>
|
<a href="#features">功能</a>
|
||||||
|
<a href="#shots">界面</a>
|
||||||
|
<a href="#how-connect">怎么连</a>
|
||||||
<a href="#how">怎么用</a>
|
<a href="#how">怎么用</a>
|
||||||
<a href="#download">下载</a>
|
<a href="#download">下载</a>
|
||||||
<a href="#faq">常见问题</a>
|
<a href="#faq">常见问题</a>
|
||||||
@@ -392,6 +403,8 @@ def render_site(files: list) -> str:
|
|||||||
<div class="steps">%(steps)s</div>
|
<div class="steps">%(steps)s</div>
|
||||||
</div></section>
|
</div></section>
|
||||||
|
|
||||||
|
%(showcase)s
|
||||||
|
|
||||||
<section id="download"><div class="wrap">
|
<section id="download"><div class="wrap">
|
||||||
<div class="sec-head">
|
<div class="sec-head">
|
||||||
<h2>下载</h2>
|
<h2>下载</h2>
|
||||||
@@ -432,6 +445,7 @@ document.querySelectorAll('a[href^="#"]').forEach(function (a) {
|
|||||||
"badge": '<div class="badge">%s</div>' % badge if badge else "",
|
"badge": '<div class="badge">%s</div>' % badge if badge else "",
|
||||||
"feats": _feature_cards(),
|
"feats": _feature_cards(),
|
||||||
"steps": _step_cards(),
|
"steps": _step_cards(),
|
||||||
|
"showcase": showcase.render_showcase(),
|
||||||
"dls": _download_cards(files),
|
"dls": _download_cards(files),
|
||||||
"faq": _faq_items(),
|
"faq": _faq_items(),
|
||||||
"_dlsub": ("当前版本 " + version) if version
|
"_dlsub": ("当前版本 " + version) if version
|
||||||
|
|||||||
+64
-543
@@ -186,6 +186,15 @@ def _list_downloads() -> list:
|
|||||||
ADMIN_SESSIONS: dict = {}
|
ADMIN_SESSIONS: dict = {}
|
||||||
ADMIN_SESSION_SECONDS = 12 * 3600
|
ADMIN_SESSION_SECONDS = 12 * 3600
|
||||||
|
|
||||||
|
# 管理后台的路径。
|
||||||
|
# 用户要求官网放 /(公开),管理后台另开一个不那么好猜的路径。
|
||||||
|
#
|
||||||
|
# 之前这个常量其实**从来没定义过** —— 路由里写的是字面量
|
||||||
|
# "/zhongzhuanzhan",而新写的 _admin_page 引用了 ADMIN_PATH,
|
||||||
|
# 结果一访问管理页就 NameError(页面 000)。
|
||||||
|
# 现在统一定义,路由和页面都引用它,避免再次不一致。
|
||||||
|
ADMIN_PATH = "/zhongzhuanzhan"
|
||||||
|
|
||||||
|
|
||||||
def admin_password() -> str:
|
def admin_password() -> str:
|
||||||
global ADMIN_PASSWORD
|
global ADMIN_PASSWORD
|
||||||
@@ -671,6 +680,16 @@ class Relay:
|
|||||||
if s.device_id == did]:
|
if s.device_id == did]:
|
||||||
self.sessions.pop(sid, None)
|
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 = []
|
devices = []
|
||||||
for device in self.devices.values():
|
for device in self.devices.values():
|
||||||
sessions = [s for s in self.sessions.values()
|
sessions = [s for s in self.sessions.values()
|
||||||
@@ -1071,6 +1090,30 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._json(200, {"ok": True, "files": _list_downloads()})
|
self._json(200, {"ok": True, "files": _list_downloads()})
|
||||||
return
|
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/"):
|
if path.startswith("/download/"):
|
||||||
wanted = urllib.parse.unquote(path[len("/download/"):])
|
wanted = urllib.parse.unquote(path[len("/download/"):])
|
||||||
safe = _safe_download_name(wanted)
|
safe = _safe_download_name(wanted)
|
||||||
@@ -1218,10 +1261,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
# 挪到 /zhongzhuanzhan(用户要求)。
|
# 挪到 /zhongzhuanzhan(用户要求)。
|
||||||
# /admin 保留成跳转,免得存过书签的人打不开。
|
# /admin 保留成跳转,免得存过书签的人打不开。
|
||||||
if path == "/admin":
|
if path == "/admin":
|
||||||
self._redirect("/zhongzhuanzhan")
|
self._redirect(ADMIN_PATH)
|
||||||
return
|
return
|
||||||
|
|
||||||
if path == "/zhongzhuanzhan":
|
if path == ADMIN_PATH:
|
||||||
# 管理页要登录 —— 上面会显示配对码,不能谁都能看
|
# 管理页要登录 —— 上面会显示配对码,不能谁都能看
|
||||||
if not self._admin_gate():
|
if not self._admin_gate():
|
||||||
self._send(200, _login_page(
|
self._send(200, _login_page(
|
||||||
@@ -1340,12 +1383,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not token:
|
if not token:
|
||||||
log("管理页登录失败(%s),来自 %s"
|
log("管理页登录失败(%s),来自 %s"
|
||||||
% (username or "未填账号", self._client_ip()))
|
% (username or "未填账号", self._client_ip()))
|
||||||
self._redirect("/zhongzhuanzhan?bad=1")
|
self._redirect(ADMIN_PATH + "?bad=1")
|
||||||
return
|
return
|
||||||
log("管理页登录成功(%s),来自 %s"
|
log("管理页登录成功(%s),来自 %s"
|
||||||
% (username, self._client_ip()))
|
% (username, self._client_ip()))
|
||||||
self.send_response(302)
|
self.send_response(302)
|
||||||
self.send_header("Location", "/zhongzhuanzhan")
|
self.send_header("Location", ADMIN_PATH)
|
||||||
self.send_header(
|
self.send_header(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
f"lr_admin={token}; Path=/; HttpOnly; SameSite=Lax; "
|
f"lr_admin={token}; Path=/; HttpOnly; SameSite=Lax; "
|
||||||
@@ -1462,552 +1505,32 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
# ---------------- 管理页 ----------------
|
# ---------------- 管理页 ----------------
|
||||||
|
|
||||||
def _admin_page(self) -> None:
|
def _admin_page(self) -> None:
|
||||||
|
"""管理后台。
|
||||||
|
|
||||||
|
实际渲染在 relay/admin.py —— 原来几百行 HTML/CSS/JS
|
||||||
|
内联在这里的 f-string 里,改一处样式要在一堆转义花括号里翻,
|
||||||
|
没法维护。拆出去之后这里只负责把数据递过去。
|
||||||
|
"""
|
||||||
|
from admin import render_admin
|
||||||
snap = RELAY.snapshot()
|
snap = RELAY.snapshot()
|
||||||
rows = []
|
self._send(200, render_admin(
|
||||||
for device in snap["devices"]:
|
snap=snap,
|
||||||
online = device["online"]
|
public=public_url() or "",
|
||||||
badge = ("<span class='badge on'>在线</span>" if online
|
admin_path=ADMIN_PATH,
|
||||||
else "<span class='badge off'>离线</span>")
|
codes=len(RELAY.codes),
|
||||||
rows.append(
|
downloads_fn=_list_downloads,
|
||||||
"<tr>"
|
).encode("utf-8"))
|
||||||
"<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>")
|
|
||||||
|
|
||||||
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>
|
# 下面这几个是页面/启动辅助(重做管理页时被误删,从 git 恢复)
|
||||||
<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")
|
|
||||||
|
|
||||||
def _esc(text) -> str:
|
def _esc(text) -> str:
|
||||||
return (str(text).replace("&", "&").replace("<", "<")
|
return (str(text).replace("&", "&").replace("<", "<")
|
||||||
.replace(">", ">").replace('"', """))
|
.replace(">", ">").replace('"', """))
|
||||||
|
|
||||||
|
|
||||||
def _kb(value: int) -> str:
|
def _kb(value: int) -> str:
|
||||||
if value < 1024:
|
if value < 1024:
|
||||||
return f"{value} B"
|
return f"{value} B"
|
||||||
@@ -2015,7 +1538,6 @@ def _kb(value: int) -> str:
|
|||||||
return f"{value / 1024:.1f} KB"
|
return f"{value / 1024:.1f} KB"
|
||||||
return f"{value / 1024 / 1024:.1f} MB"
|
return f"{value / 1024 / 1024:.1f} MB"
|
||||||
|
|
||||||
|
|
||||||
def _uptime(seconds: int) -> str:
|
def _uptime(seconds: int) -> str:
|
||||||
days, rest = divmod(int(seconds), 86400)
|
days, rest = divmod(int(seconds), 86400)
|
||||||
hours, rest = divmod(rest, 3600)
|
hours, rest = divmod(rest, 3600)
|
||||||
@@ -2036,7 +1558,6 @@ class Server(ThreadingHTTPServer):
|
|||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
request_queue_size = 64
|
request_queue_size = 64
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user