diff --git a/_build/_missing_tail.py b/_build/_missing_tail.py
new file mode 100644
index 0000000..50cd0af
--- /dev/null
+++ b/_build/_missing_tail.py
@@ -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())
diff --git a/_build/_shotdata2/settings.json b/_build/_shotdata2/settings.json
new file mode 100644
index 0000000..0ba41d1
--- /dev/null
+++ b/_build/_shotdata2/settings.json
@@ -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
+}
\ No newline at end of file
diff --git a/_build/_shots_data/settings.json b/_build/_shots_data/settings.json
new file mode 100644
index 0000000..6a3cee2
--- /dev/null
+++ b/_build/_shots_data/settings.json
@@ -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
+}
\ No newline at end of file
diff --git a/_build/admin_new.html b/_build/admin_new.html
new file mode 100644
index 0000000..a930dd2
--- /dev/null
+++ b/_build/admin_new.html
@@ -0,0 +1,463 @@
+
+
+
-
- yx
-
-
ykxmz 中转服务器
-
远程控制 · 跨网络中转
-
-
-
+
+
yx
+
+ ykxmz 中转站
+ 跨网络中转 · 管理后台
+
+
运行中
+
+
版本 1.7.1 · 已运行 9 小时 10 分 · 内存 24 MB
+
+
+
-
-
当前公网地址 —— 手表「中转设置」里填这个
-
-
https://relay.xxmxxs.top
-
-
-
+
-
-
网络体检 —— 走的是局域网还是隧道?
-
点下面按钮测一下
-
-
-
-
+
+
公网地址 —— 手表「中转设置」里填这个
+
+
https://relay.xxmxxs.top
+
+
+
手表和电脑不在同一个网络时,两边都通过这个地址中转。
+
-
+
-
+
+
设备
+
+
+
+
+
+
-
-
- 删除会连配对码一起清掉,那台设备下次可以重新设码。
-
+
-
+
-
+
-
-
+
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/_build/commit_round79.py b/_build/commit_round79.py
new file mode 100644
index 0000000..615141f
--- /dev/null
+++ b/_build/commit_round79.py
@@ -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())
diff --git a/_build/compare_protocol.sh b/_build/compare_protocol.sh
new file mode 100644
index 0000000..a1c626d
--- /dev/null
+++ b/_build/compare_protocol.sh
@@ -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 并重启。"
diff --git a/_build/deep_tunnel.sh b/_build/deep_tunnel.sh
new file mode 100644
index 0000000..9f91cb1
--- /dev/null
+++ b/_build/deep_tunnel.sh
@@ -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/^/ /'
diff --git a/_build/diag_admin000.sh b/_build/diag_admin000.sh
new file mode 100644
index 0000000..4838452
--- /dev/null
+++ b/_build/diag_admin000.sh
@@ -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/^/ /'
diff --git a/_build/diag_origin.sh b/_build/diag_origin.sh
new file mode 100644
index 0000000..fb12d19
--- /dev/null
+++ b/_build/diag_origin.sh
@@ -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
diff --git a/_build/diag_relay2.sh b/_build/diag_relay2.sh
new file mode 100644
index 0000000..6ce1066
--- /dev/null
+++ b/_build/diag_relay2.sh
@@ -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/^/ /'
diff --git a/_build/diag_relay3.sh b/_build/diag_relay3.sh
new file mode 100644
index 0000000..a04cab4
--- /dev/null
+++ b/_build/diag_relay3.sh
@@ -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/^/ /'
diff --git a/_build/diag_relay_crash.sh b/_build/diag_relay_crash.sh
new file mode 100644
index 0000000..261a1f5
--- /dev/null
+++ b/_build/diag_relay_crash.sh
@@ -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/^/ /'
diff --git a/_build/diff_relay_loss.py b/_build/diff_relay_loss.py
new file mode 100644
index 0000000..a2da6b4
--- /dev/null
+++ b/_build/diff_relay_loss.py
@@ -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())
diff --git a/_build/finalize_tunnel.sh b/_build/finalize_tunnel.sh
index 83dd00d..219ca27 100644
--- a/_build/finalize_tunnel.sh
+++ b/_build/finalize_tunnel.sh
@@ -1,117 +1,67 @@
#!/bin/bash
-# 定稿:ha-connections=2,并做长时间稳定性验证。
-#
-# 实测(见 /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 是甜点:够稳,又还有冗余。
+# 定稿:HTTP/2 + 4 连接,然后做**长时间**验证(这次不看瞬时值)
set -u
-RELAY_DIR=/root/lanremote-relay
UNIT=/etc/systemd/system/lanremote-tunnel.service
+PUB=https://relay.xxmxxs.top
+RELAY_DIR=/root/lanremote-relay
-echo "===== 1. 写入正式配置(ha=2)====="
-cat > "$UNIT" <<'EOF'
-[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"
+echo "════════ 1. 写回最优配置 ════════"
+sed -i "s/--protocol [a-z0-9]*/--protocol http2/" "$UNIT"
+sed -i "s/--ha-connections [0-9]*/--ha-connections 4/" "$UNIT"
systemctl daemon-reload
-systemctl enable lanremote-tunnel >/dev/null 2>&1
-systemctl start lanremote-tunnel
+systemctl restart lanremote-tunnel
+echo " $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$UNIT" | tr '\n' ' ')"
echo
-echo "===== 2. 等它建齐连接 ====="
-for i in 1 2 3 4 5; do
- sleep 15
- N=$(grep -c 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null || echo 0)
- echo " $((i*15)) 秒:已注册 $N 条"
- [ "$N" -ge 2 ] && break
+echo "════════ 2. 等连接全部建立 ════════"
+for i in $(seq 1 12); do
+ sleep 5
+ n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
+ | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
+ echo " ${i}0s 内: 连接 $n"
+ [ "$n" = "4" ] && break
done
echo
-echo "===== 3. 长时间稳定性:连打 30 次,每次隔 3 秒 ====="
-OK=0
-FAIL=0
-FAILED_CODES=""
-for i in $(seq 1 30); do
- C=$(timeout 20 curl -4 -s -o /dev/null -w '%{http_code}' \
- -H 'User-Agent: LanRemote/1.4.0' \
- https://relay.xxmxxs.top/v1/health 2>/dev/null)
- if [ "$C" = "200" ]; then
- OK=$((OK + 1)); M="✓"
+echo "════════ 3. 公网连测 40 次(约 2 分钟)════════"
+ok=0; fail=0; worst=0; streak=0; worst_streak=0
+for i in $(seq 1 40); do
+ r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 15 \
+ -H 'User-Agent: LanRemote/1.4.0' "$PUB/v1/health")
+ c=$(echo "$r" | cut -d' ' -f1)
+ t=$(echo "$r" | cut -d' ' -f2)
+ if [ "$c" = "200" ]; then
+ ok=$((ok+1)); streak=0
+ big=$(echo "$t > $worst" | bc 2>/dev/null || echo 0)
+ [ "$big" = "1" ] && worst=$t
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
- printf " %2d: %s %s\n" "$i" "$M" "${C:-超时}"
- sleep 3
+ sleep 2.5
done
-PCT=$((OK * 100 / 30))
+rate=$(echo "scale=1; $ok * 100 / 40" | bc)
echo
-echo " 成功 $OK / 30 ($PCT%)"
-[ -n "$FAILED_CODES" ] && echo " 失败的返回码:$FAILED_CODES"
+echo " 可用 $ok/40 = ${rate}% 最慢 ${worst}s"
+echo " 最长连续失败 $worst_streak 次(约 $((worst_streak * 3)) 秒)"
echo
-echo "===== 4. 对照:中转本地(应该 100%)====="
-LOK=0
-for i in $(seq 1 10); do
- C=$(timeout 8 curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/v1/health)
- [ "$C" = "200" ] && LOK=$((LOK + 1))
-done
-echo " 局域网直连 $LOK / 10"
-
-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%),看门狗会兜底"
+echo "════════ 4. 这段时间隧道重连了几次 ════════"
+since=$(date -u -d '4 minutes ago' '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "")
+if [ -n "$since" ]; then
+ awk -v s="$since" '$0 >= s' "$RELAY_DIR/tunnel.log" 2>/dev/null > /tmp/recent.log
+ for kw in 'Registered tunnel connection' 'Lost connection' 'Retrying connection' 'i/o timeout'; do
+ printf " %-32s %s 次\n" "$kw" "$(grep -c "$kw" /tmp/recent.log)"
+ done
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/^/ /'
diff --git a/_build/grab_admin2.py b/_build/grab_admin2.py
new file mode 100644
index 0000000..898ff3a
--- /dev/null
+++ b/_build/grab_admin2.py
@@ -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())
diff --git a/_build/list_windows.py b/_build/list_windows.py
new file mode 100644
index 0000000..2600dad
--- /dev/null
+++ b/_build/list_windows.py
@@ -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())
diff --git a/_build/polish_site.py b/_build/polish_site.py
new file mode 100644
index 0000000..d3d9268
--- /dev/null
+++ b/_build/polish_site.py
@@ -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 = '''
'''
+ new_nav = '''
'''
+ 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 = '
● 已连接 · 局域网 3 ms'
+ new = '
● 局域网 3 ms'
+ 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())
diff --git a/_build/restore_main.py b/_build/restore_main.py
new file mode 100644
index 0000000..34805cb
--- /dev/null
+++ b/_build/restore_main.py
@@ -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())
diff --git a/_build/restore_missing.py b/_build/restore_missing.py
new file mode 100644
index 0000000..5b687c9
--- /dev/null
+++ b/_build/restore_missing.py
@@ -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())
diff --git a/_build/shoot_pc_main.py b/_build/shoot_pc_main.py
new file mode 100644
index 0000000..31ca074
--- /dev/null
+++ b/_build/shoot_pc_main.py
@@ -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())
diff --git a/_build/shoot_pc_window.py b/_build/shoot_pc_window.py
new file mode 100644
index 0000000..d11c50e
--- /dev/null
+++ b/_build/shoot_pc_window.py
@@ -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())
diff --git a/_build/site_preview.html b/_build/site_preview.html
index fbae8ef..a44e734 100644
--- a/_build/site_preview.html
+++ b/_build/site_preview.html
@@ -115,6 +115,68 @@ footer{padding:52px 0 64px;border-top:1px solid #e6e8eb;
.foot-links{display:flex;gap:22px}
.foot-links a:hover{color:#1a1d21}
.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}
@@ -159,6 +221,52 @@ footer{padding:52px 0 64px;border-top:1px solid #e6e8eb;
1
电脑端
在教室那台 Windows 电脑上运行安装包,填一个 6 位配对码,装完自动后台运行
2
手表端
在安卓手表上装 APK,首页点「局域网连接」或「输入配对码连接」
3
连上就用
配对一次之后,以后打开就自动连,不用再输数字
+
+