Files
ykxmz/relay/landing.py
T
xmz7891 1f05084f51 隧道"假活"真因修复 + 中转管理后台重做
用户反馈两件事:
  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。现在统一定义了常量。

二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
2026-10-07 09:34:53 +08:00

454 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
r'''官网页面渲染(独立模块)。
为什么单独一个文件:
relay.py 已经 2000+ 行,再塞几百行 HTML 会没法看。
网站是"展示层",跟中转逻辑没关系,分开更清楚。
打包脚本按 relay/*.py 全收,所以拆出去不影响部署。
设计理念(用户要求):**白色、简洁**。
底白 #ffffff,浅灰面板 #fafbfc,边框 #e6e8eb,
正文近黑 #1a1d21,次要灰 #6b7280,主色蓝 #2563eb。
大量留白,细边框,不用阴影堆砌。
信息结构(参考成熟产品站):
吸顶导航 -> Hero(标题+副标题+两个 CTA)-> 功能网格
-> 三步上手 -> 下载 -> 常见问题 -> 页脚
'''
from __future__ import annotations
import re
from urllib.parse import quote
import showcase
# ---------------- 设计令牌 ----------------
BG = "#ffffff"
SURFACE = "#fafbfc"
BORDER = "#e6e8eb"
TEXT = "#1a1d21"
MUTED = "#6b7280"
FAINT = "#9ca3af"
ACCENT = "#2563eb"
OK = "#16a34a"
# 内联 SVG 图标(不引外部资源,省得被墙/慢)
_ICONS = {
"screen": '<path d="M3 5h18v11H3z"/><path d="M1 20h22"/>',
"play": '<path d="M6 4l14 8-14 8z"/>',
"image": '<rect x="3" y="4" width="18" height="16" rx="2"/>'
'<circle cx="8.5" cy="9.5" r="1.5"/><path d="M21 16l-5-5-9 9"/>',
"file": '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>'
'<path d="M14 2v6h6"/>',
"shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/>',
"power": '<path d="M12 2v10"/><path d="M6 6a9 9 0 1 0 12 0"/>',
"watch": '<rect x="6" y="6" width="12" height="12" rx="3"/>'
'<path d="M9 6V3h6v3M9 18v3h6v-3"/>',
"wifi": '<path d="M5 12.5a10 10 0 0 1 14 0"/>'
'<path d="M8.5 16a5.5 5.5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/>',
"lock": '<rect x="4" y="10" width="16" height="11" rx="2"/>'
'<path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
}
def _icon(name: str, size: int = 22) -> str:
body = _ICONS.get(name, "")
return (
'<svg width="%d" height="%d" viewBox="0 0 24 24" fill="none" '
'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" '
'stroke-linejoin="round">%s</svg>' % (size, size, body)
)
FEATURES = [
("screen", "屏幕查看", "手表上看教室里那台电脑在显示什么,"
"顺手一键截屏存到手表"),
("play", "课件播放", "放音频、放视频、调音量、静音 —— "
"不用走回讲台去点鼠标"),
("image", "投图到大屏", "手表上选张图,全屏显示在教室大屏。"
"考试时贴个提示特别方便"),
("file", "传文件", "课件在两台机器之间来回倒腾,"
"不用插 U 盘"),
("watch", "课堂专注", "一键限制游戏相关站点、关闭游戏窗口,"
"下课一键恢复"),
("power", "电源控制", "锁屏、睡眠、关机、重启,"
"还能设「30 分钟后自动关机」"),
("wifi", "局域网优先", "手表和电脑在同一个 WiFi 时直连,"
"几十毫秒,不绕服务器"),
("shield", "跨网络", "不在同一个网络时自动走中转,"
"办公室也能管教室"),
("lock", "可见可控", "屏幕被看时电脑上一定有提示条,"
"关不掉;本机随时能掐断"),
]
STEPS = [
("电脑端", "在教室那台 Windows 电脑上运行安装包,"
"填一个 6 位配对码,装完自动后台运行"),
("手表端", "在安卓手表上装 APK,首页点「局域网连接」"
"或「输入配对码连接」"),
("连上就用", "配对一次之后,以后打开就自动连,"
"不用再输数字"),
]
FAQ = [
("手表连不上电脑怎么办?",
"先确认电脑上程序在运行(任务管理器里找 ykxmz.exe)。"
"如果被控端窗口提示「防火墙未放行」,点旁边的一键修复。"
"还是不行就跑一次网络自检 —— 电脑上打开工具箱选 3,"
"然后手表上点「检查服务器」。"),
("为什么显示「延迟 2000 毫秒」这么慢?",
"跨网络时数据要绕到中转服务器再回来,"
"延迟取决于你那台小机器的线路。"
"如果手表和电脑在同一个 WiFi,会自动走局域网直连,"
"延迟通常只有几十毫秒。"),
("需要另外买服务器吗?",
"只有一个教室用的话不需要 —— 手表和电脑连同一个 WiFi 就能直连。"
"要在办公室管教室才需要一台能上外网的小机器当中转。"),
("配对码忘了怎么办?",
"电脑上双击程序图标打开窗口就能看到。"
"也可以在窗口里改一个自己好记的。"),
("手表重装了还能连上原来的电脑吗?",
"能。重新输一次那个 6 位配对码就行 —— "
"配对码本身就是钥匙。"),
("屏幕被别人看着,我能知道吗?",
"能。只要有人在看你的屏幕,电脑右上角就会出现一条红色提示条,"
"没有开关能关掉它。电脑前面的人随时可以点「停止共享」断开。"),
]
def _logo(size: int = 26) -> str:
"""内联 SVG 的 logo。
为什么不用 <img src="/favicon.ico">:
ICO 放在 <img> 里各浏览器支持不一致,实测截图里是破图。
内联 SVG 永远能显示,还不用多一个请求。
"""
# 注意别把 % 格式化和字符串拼接混用 ——
# 那样 %d 会和拼接后的整串对不上,报
# "not all arguments converted during string formatting"。
# 这里全部用 .format() 拼,清楚也不会踩坑。
return (
'<svg width="{s}" height="{s}" viewBox="0 0 32 32" fill="none" '
'aria-hidden="true">'
'<rect x="3" y="3" width="26" height="26" rx="8" fill="{c}"/>'
'<rect x="10" y="9" width="12" height="14" rx="4" '
'stroke="#fff" stroke-width="2"/>'
'<path d="M13 9V6h6v3M13 23v3h6v-3" stroke="#fff" '
'stroke-width="2" stroke-linecap="round"/>'
'</svg>'
).format(s=size, c=ACCENT)
def _download_cards(files: list) -> str:
"""三个下载卡片。files 是 _list_downloads() 的结果。"""
def pick(suffix, keyword=""):
for item in files:
name = item["name"].lower()
if name.endswith(suffix) and (not keyword or keyword in name):
return item
return None
specs = [
("电脑端", "教室那台 Windows 电脑", "安装包",
pick(".exe"), "装完自动后台运行", "Windows 10 / 11"),
("手表端", "安卓手表", "APK",
pick(".apk"), "直接安装,不用 root", "Android 7.0+"),
("服务器端", "一台能上外网的小机器", "压缩包",
pick(".zip", "server"), "只在跨网络时才需要", "Debian / Ubuntu"),
]
cards = []
for title, who, tag, item, note, req in specs:
if item:
mb = item["size"] / 1024 / 1024
size = ("%.1f MB" % mb) if mb >= 0.1 else ("%.0f KB" % (item["size"] / 1024))
action = (
'<a class="dl-btn" href="/download/%s">下载 %s</a>'
'<div class="dl-file">%s · %s</div>'
% (quote(item["name"]), tag, item["name"], size)
)
else:
action = '<div class="dl-soon">暂未上传</div>'
cards.append(
'<div class="dl-card">'
'<div class="dl-head"><span class="dl-tag">%s</span>'
'<span class="dl-who">%s</span></div>'
'<div class="dl-note">%s</div>'
'%s'
'<div class="dl-req">%s</div>'
'</div>' % (title, who, note, action, req)
)
return "".join(cards)
def _feature_cards() -> str:
out = []
for icon, title, desc in FEATURES:
out.append(
'<div class="feat"><div class="feat-icon">%s</div>'
'<div class="feat-title">%s</div>'
'<div class="feat-desc">%s</div></div>'
% (_icon(icon), title, desc)
)
return "".join(out)
def _step_cards() -> str:
out = []
for index, (title, desc) in enumerate(STEPS, 1):
out.append(
'<div class="step"><div class="step-num">%d</div>'
'<div class="step-title">%s</div>'
'<div class="step-desc">%s</div></div>' % (index, title, desc)
)
return "".join(out)
def _faq_items() -> str:
out = []
for question, answer in FAQ:
out.append(
'<details class="faq"><summary>%s<span class="faq-mark">'
'</span></summary><div class="faq-body">%s</div></details>'
% (question, answer)
)
return "".join(out)
def _version(files: list) -> str:
for item in files:
found = re.search(r"(\d+\.\d+\.\d+)", item["name"])
if found:
return found.group(1)
return ""
SITE_CSS = """
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",
"Microsoft YaHei","PingFang SC",sans-serif;
background:%(BG)s;color:%(TEXT)s;line-height:1.75;
-webkit-font-smoothing:antialiased}
a{color:inherit;text-decoration:none}
.wrap{max-width:960px;margin:0 auto;padding:0 24px}
/* 吸顶导航 */
nav{position:sticky;top:0;z-index:50;background:rgba(255,255,255,.86);
backdrop-filter:saturate(180%%) blur(12px);
border-bottom:1px solid %(BORDER)s}
.nav-in{display:flex;align-items:center;justify-content:space-between;
height:60px}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;
font-size:17px;letter-spacing:-.2px}
.brand img{width:26px;height:26px;border-radius:7px}
.nav-links{display:flex;gap:26px;font-size:14px;color:%(MUTED)s}
.nav-links a:hover{color:%(TEXT)s}
/* Hero */
.hero{padding:88px 0 72px;text-align:center}
.hero h1{font-size:46px;line-height:1.2;letter-spacing:-1.2px;
font-weight:800;margin-bottom:18px}
.hero p{font-size:18px;color:%(MUTED)s;max-width:600px;margin:0 auto 34px}
.cta{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;gap:8px;
padding:12px 26px;border-radius:10px;font-weight:600;font-size:15px;
transition:all .15s}
.btn-p{background:%(ACCENT)s;color:#fff}
.btn-p:hover{background:#1d4ed8;transform:translateY(-1px)}
.btn-s{background:%(SURFACE)s;color:%(TEXT)s;
border:1px solid %(BORDER)s}
.btn-s:hover{border-color:#d1d5db;background:#f3f4f6}
.badge{display:inline-block;margin-top:22px;padding:4px 14px;
border-radius:999px;background:#eff6ff;color:%(ACCENT)s;
font-size:13px;font-weight:600}
/* 通用区段 */
section{padding:72px 0;border-top:1px solid %(BORDER)s}
.sec-head{text-align:center;margin-bottom:48px}
.sec-head h2{font-size:30px;letter-spacing:-.6px;font-weight:700;
margin-bottom:10px}
.sec-head p{color:%(MUTED)s;font-size:16px}
/* 功能网格 */
.feats{display:grid;gap:1px;background:%(BORDER)s;
border:1px solid %(BORDER)s;border-radius:14px;overflow:hidden}
@media(min-width:640px){.feats{grid-template-columns:repeat(3,1fr)}}
.feat{background:%(BG)s;padding:28px 24px}
.feat-icon{color:%(ACCENT)s;margin-bottom:14px}
.feat-title{font-weight:650;font-size:16px;margin-bottom:6px}
.feat-desc{color:%(MUTED)s;font-size:14px;line-height:1.7}
/* 三步 */
.steps{display:grid;gap:20px}
@media(min-width:640px){.steps{grid-template-columns:repeat(3,1fr)}}
.step{padding:26px 22px;border:1px solid %(BORDER)s;border-radius:14px;
background:%(SURFACE)s}
.step-num{width:30px;height:30px;border-radius:50%%;background:%(ACCENT)s;
color:#fff;display:flex;align-items:center;justify-content:center;
font-weight:700;font-size:14px;margin-bottom:14px}
.step-title{font-weight:650;font-size:16px;margin-bottom:6px}
.step-desc{color:%(MUTED)s;font-size:14px;line-height:1.7}
/* 下载 */
.dls{display:grid;gap:16px}
@media(min-width:760px){.dls{grid-template-columns:repeat(3,1fr)}}
.dl-card{border:1px solid %(BORDER)s;border-radius:14px;padding:24px 20px;
display:flex;flex-direction:column;background:%(BG)s;
transition:border-color .15s}
.dl-card:hover{border-color:#d1d5db}
.dl-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}
.dl-tag{font-weight:700;font-size:17px}
.dl-who{color:%(FAINT)s;font-size:13px}
.dl-note{color:%(MUTED)s;font-size:13px;margin-bottom:18px;flex:1}
.dl-btn{display:block;text-align:center;background:%(ACCENT)s;color:#fff;
padding:11px 16px;border-radius:9px;font-weight:600;font-size:14px;
transition:background .15s}
.dl-btn:hover{background:#1d4ed8}
.dl-file{color:%(FAINT)s;font-size:11px;margin-top:9px;text-align:center;
word-break:break-all}
.dl-soon{text-align:center;color:%(FAINT)s;font-size:14px;padding:11px 0;
border:1px dashed %(BORDER)s;border-radius:9px}
.dl-req{color:%(FAINT)s;font-size:12px;margin-top:12px;text-align:center}
/* FAQ */
.faqs{max-width:720px;margin:0 auto}
.faq{border-bottom:1px solid %(BORDER)s}
.faq summary{padding:20px 0;font-weight:600;font-size:16px;cursor:pointer;
list-style:none;display:flex;justify-content:space-between;
align-items:center;gap:16px}
.faq summary::-webkit-details-marker{display:none}
.faq-mark{width:9px;height:9px;border-right:2px solid %(FAINT)s;
border-bottom:2px solid %(FAINT)s;transform:rotate(45deg);
transition:transform .2s;flex:none;margin-right:4px}
.faq[open] .faq-mark{transform:rotate(-135deg)}
.faq-body{color:%(MUTED)s;font-size:15px;padding:0 0 22px;line-height:1.8}
/* 页脚 */
footer{padding:52px 0 64px;border-top:1px solid %(BORDER)s;
color:%(MUTED)s;font-size:14px}
.foot-in{display:flex;justify-content:space-between;gap:24px;
flex-wrap:wrap;align-items:center}
.foot-links{display:flex;gap:22px}
.foot-links a:hover{color:%(TEXT)s}
.copy{color:%(FAINT)s;font-size:13px;margin-top:26px;text-align:center}
""" % {"BG": BG, "SURFACE": SURFACE, "BORDER": BORDER, "TEXT": TEXT,
"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:
"""渲染官网首页。files 是 _list_downloads() 的结果。"""
version = _version(files)
badge = ("最新版 " + version) if version else ""
return ("""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ykxmz · 教室电脑手表遥控</title>
<meta name="description" content="老师用手表管教室里那台电脑:\u653e\u8bfe\u4ef6\u3001\u6295\u56fe\u3001\u4e0b\u8bfe\u5173\u673a\uff0c\u4e0d\u7528\u8d70\u56de\u8bb2\u53f0\u3002">
<link rel="icon" href="/favicon.ico">
<style>%(css)s</style>
</head>
<body>
<nav><div class="wrap nav-in">
<a class="brand" href="/">
__LOGO__
<span>ykxmz</span>
</a>
<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>
</div></nav>
<div class="wrap hero">
<h1>老师抬一下手腕<br>就能管教室那台电脑</h1>
<p>放课件、放音频、投图到大屏、看看有没有人在跑无关程序、下课关机 ——
不用走回讲台去点鼠标。</p>
<div class="cta">
<a class="btn btn-p" href="#download">下载安装</a>
<a class="btn btn-s" href="#how">先看看怎么用</a>
</div>
%(badge)s
</div>
<section id="features"><div class="wrap">
<div class="sec-head">
<h2>上课用得上的,基本都在这</h2>
<p>手表上十个分页,常用操作都在第一屏</p>
</div>
<div class="feats">%(feats)s</div>
</div></section>
<section id="how"><div class="wrap">
<div class="sec-head">
<h2>三步装好</h2>
<p>电脑装一个程序,手表装一个 App,一台电脑配一次就行</p>
</div>
<div class="steps">%(steps)s</div>
</div></section>
%(showcase)s
<section id="download"><div class="wrap">
<div class="sec-head">
<h2>下载</h2>
<p>%(_dlsub)s</p>
</div>
<div class="dls">%(dls)s</div>
</div></section>
<section id="faq"><div class="wrap">
<div class="sec-head"><h2>常见问题</h2></div>
<div class="faqs">%(faq)s</div>
</div></section>
<footer><div class="wrap">
<div class="foot-in">
<div class="brand">__LOGO__<span>ykxmz</span></div>
<div class="foot-links">
<a href="https://gitee.com/xmz7891/ykxmz" target="_blank" rel="noopener">源码</a>
<a href="https://gitee.com/xmz7891/ykxmz/releases" target="_blank" rel="noopener">历史版本</a>
<a href="#faq">常见问题</a>
</div>
</div>
<div class="copy">ykxmz · 教室电脑手表遥控</div>
</div></footer>
<script>
// 点导航链接后收起移动端(这里没做汉堡菜单,留个锚点平滑滚动就行)
document.querySelectorAll('a[href^="#"]').forEach(function (a) {
a.addEventListener('click', function (e) {
var target = document.querySelector(a.getAttribute('href'));
if (target) { e.preventDefault(); target.scrollIntoView({behavior:'smooth'}); }
});
});
</script>
</body>
</html>""" % {
"css": SITE_CSS,
"badge": '<div class="badge">%s</div>' % badge if badge else "",
"feats": _feature_cards(),
"steps": _step_cards(),
"showcase": showcase.render_showcase(),
"dls": _download_cards(files),
"faq": _faq_items(),
"_dlsub": ("当前版本 " + version) if version
else "选择你的设备",
}).replace("__LOGO__", _logo())