mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 23:00: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。现在统一定义了常量。
二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
454 lines
18 KiB
Python
454 lines
18 KiB
Python
# -*- 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())
|