Files
ykxmz/relay/landing.py
T
xmz7891 566d9e384f 阶段1+2:恢复 HTTP/2 加速 + 重做官网
阶段1 网络加速
- 发现隧道在跑 QUIC(ExecStart 丢了 --protocol http2),
  实测 QUIC 可用率 48% vs HTTP/2 80%+,是重大性能回退。
  恢复后公网延迟从 4141ms 降到 690ms(快 6 倍),可用率 92%。
- 优选 IP 结论:无效。hosts 覆盖确实生效了(日志里连的就是指定 IP),
  但落点仍是 lax —— 运营商把所有 Cloudflare anycast 都路由到美西。
  已清掉这段无效配置。
- BBR 不可用:设备是自定义内核 6.4.0-rc4-jsbsbxjxh66,
  没编 tcp_bbr 模块也没配置项。改用 cubic + 调优:
  TCP 缓冲 4MB、tcp_fastopen=3、tcp_slow_start_after_idle=0
  (长轮询场景关键)、tcp_tw_reuse=1。

阶段2 官网重做
- 拆出独立模块 relay/landing.py(relay.py 已两千多行,
  再塞 HTML 没法看)
- 信息结构参考成熟产品站:吸顶导航 / Hero / 九宫格功能 /
  三步上手 / 下载 / FAQ / 页脚
- 保持白色简洁:底白、浅灰面板、细边框、大量留白
- logo 用内联 SVG(ICO 放 img 里实测是破图)
- 管理入口从官网移除(管理不对外开放)

过程中被自己的测试抓到两个 bug:
- 模块叫 site.py 会和 Python 标准库重名,import site 拿到的是标准库
- _logo() 里把 % 格式化和字符串拼接混用,直接抛 TypeError
2026-10-06 23:53:19 +08:00

440 lines
17 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
# ---------------- 设计令牌 ----------------
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}
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="#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>
<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(),
"dls": _download_cards(files),
"faq": _faq_items(),
"_dlsub": ("当前版本 " + version) if version
else "选择你的设备",
}).replace("__LOGO__", _logo())