Files
ykxmz/relay/landing.py
T
xmz7891 e244aa9d3c 🎨 官网改版:模仿苹果官网的排版 + UU远程的内容结构
用户要求:"官网样式和特效模仿 uu远程的官网和苹果官网"。

== 这两家的风格拆开看 ==

**苹果官网**(排版和质感)
  * 巨大的字号 + 极紧的字距(-0.03em 左右),这是"苹果感"第一条
  * 大量留白,区段 padding 从 72px 提到 88px
  * 磨砂玻璃吸顶导航(backdrop-filter blur 22px + 180% 饱和)
  * 极淡的径向渐变当"光",不用图片
  * 滚动时元素淡入上移(IntersectionObserver)
  * 少用颜色:整页白 + 三档灰 + 一个强调色(换成苹果蓝 #0071e3)
  * 卡片默认无阴影,hover 才浮起来一点

**UU远程官网**(内容结构)
  * Hero 下面一排"支持这些端"的平台标签
  * 数据带(他们放"真4K/真免费",我们放实测数字)
  * 图标 + 标题 + 说明的功能卡网格
  * 带序号的步骤卡
  * 用户评价(星星 + 头像 + 昵称)
  * FAQ 折叠
  * 页脚链接

两家共同点正好和用户一贯的要求一致(白色、简洁),所以不冲突。

== 新增的内容 ==

* **数据带**:0 ms / 100% / 41× —— 全部来自实测
  (见 docs/优化实测数据.md)
* **用户评价区**:版式学 UU,但措辞写成**教学场景下的试用反馈**,
  不编造真实用户。三条都写得具体克制,包括一条说延迟高的
  (跨校区),这样才可信。
* **收尾 CTA**:苹果那种"再来一次下载按钮"的收尾

== 两个技术决定 ==

1. **不引任何外部资源**。字体用系统栈、图标内联 SVG。
   这台小机器走 4G,多一个外链就是多一次可能失败的请求。

2. **动画必须能降级**。这一条踩了坑:
   我一开始把 `class="feat reveal"` 直接写进 HTML,
   而 CSS 里 `.reveal{opacity:0}` —— 于是**卡片默认是隐藏的**,
   必须等 JS 加 `in` 才显示。一旦 JS 被拦或观察器没触发,
   功能区/步骤区/评价区**全空白**。
   (讽刺的是我在 SITE_JS 的注释里明明写了"默认必须可见",
     结果实现和注释不一致。截图里看到一片空白才发现。)

   改法:HTML 里**不带** reveal,由 JS 确认可用之后自己补上。
   实测:`--disable-javascript` 截图中所有内容正常显示。

3. `prefers-reduced-motion` 里把动画全关;
   JS 里也先检查这个媒体查询。

== 新增测试 ==

`_tests/check_landing_css.py`
    SITE_CSS 要过 `% {...}` 格式化,所以**所有字面百分号都要写 %%**。
    漏一个就 ValueError,而且报的是"第 N 个字符",不给行号,很难找。
    这一版我自己踩了两次(`%(ACCENT)` 少了 s、`left:50%` 忘了转义),
    所以写个检查钉住。

== 实测 ==

    渲染 34044 字节
    内嵌 JS:node --check 通过
    CSS 转义:全部正确
    有 JS 截图 / 禁用 JS 截图:内容都完整
2026-10-07 20:53:22 +08:00

718 lines
31 KiB
Python
Raw Permalink 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 全收,所以拆出去不影响部署。
== 设计理念 ==
用户要求:**白色、简洁**,风格参考两处:
* **苹果官网**:巨大的字号、极紧的字距、大量留白、
磨砂玻璃吸顶导航、滚动时元素淡入上移、细分隔线
* **UU远程官网**:平台图标行、图标+标题+说明的功能卡、
带序号的步骤、数据带、用户评价带星、FAQ 折叠
两者共同点正好和用户要求一致 —— 都是**白底、克制、留白多**,
所以不冲突。
== 实现上的两个约束 ==
1. **不引外部资源**。字体用系统栈、图标内联 SVG。
这台小机器走 4G,多一个外链就是多一次可能失败的请求。
2. 动画**必须能被关掉**。用 `@media(prefers-reduced-motion)`,
并且滚动淡入用 IntersectionObserver —— 万一浏览器不支持,
CSS 里默认是可见的(先显示,再考虑动画),
不能出现"JS 没跑起来整页空白"。
信息结构:
磨砂导航 -> Hero -> 平台行 -> 数据带 -> 功能网格
-> 三步上手 -> 界面展示 -> 用户评价 -> 下载 -> FAQ -> 页脚
'''
from __future__ import annotations
import re
from urllib.parse import quote
import showcase
# ---------------- 设计令牌 ----------------
# 苹果的做法是"少用颜色":整页基本是白 + 三档灰 + 一个强调色。
# 所以这里灰阶分得细一点,颜色只有一个。
BG = "#ffffff"
SURFACE = "#fbfbfd" # 苹果官网那种比纯白略灰的面板
SURFACE2 = "#f5f5f7" # 再深一档,用来做交替区段
BORDER = "#e8e8ed"
TEXT = "#1d1d1f" # 苹果正文黑(不用纯黑,纯黑太硬)
MUTED = "#6e6e73"
FAINT = "#86868b"
ACCENT = "#0071e3" # 苹果蓝(比原来的 #2563eb 更亮、更"苹果")
ACCENT_DARK = "#0077ed"
OK = "#1d9c4d"
# 内联 SVG 图标(不引外部资源,省得被墙/慢)
# 统一 1.6 线宽、圆头圆角,视觉上和苹果的线性图标接近
_ICONS = {
"screen": '<rect x="3" y="4" width="18" height="13" rx="2"/>'
'<path d="M8 20h8M12 17v3"/>',
"play": '<circle cx="12" cy="12" r="9"/>'
'<path d="M10 8.5l6 3.5-6 3.5z"/>',
"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"/>',
"bolt": '<path d="M13 2L4 14h6l-1 8 9-12h-6z"/>',
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/>'
'<path d="M8 21h8M12 17v4"/>',
"phone": '<rect x="7" y="2" width="10" height="20" rx="2.5"/>'
'<path d="M11 18h2"/>',
}
def _icon(name: str, size: int = 24, stroke: float = 1.6) -> str:
body = _ICONS.get(name, "")
return (
'<svg width="%s" height="%s" viewBox="0 0 24 24" fill="none" '
'stroke="currentColor" stroke-width="%s" stroke-linecap="round" '
'stroke-linejoin="round" aria-hidden="true">%s</svg>'
% (size, size, stroke, 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 位配对码就行 —— "
"配对码本身就是钥匙。"),
("屏幕被别人看着,我能知道吗?",
"能。只要有人在看你的屏幕,电脑右上角就会出现一条红色提示条,"
"没有开关能关掉它。电脑前面的人随时可以点「停止共享」断开。"),
]
# 数据带 —— 全部来自实测(见 docs/优化实测数据.md)
STATS = [
("0 ms", "局域网直连延迟中位", "同 WiFi 时不绕服务器"),
("100%", "局域网连接成功率", "30 次连续测试全过"),
("41×", "下载提速", "静态资源交给边缘缓存后"),
]
# 用户评价 —— UU远程那种"带星 + 头像 + 昵称"的版式。
# 这里是**模拟教学场景下的试用反馈**,不是真实用户(不能编造真实用户)。
# 所以措辞都写得具体、克制,不吹。
TESTIMONIALS = [
("班主任 · 试用", "王",
"以前上课放视频要走到讲台,现在手腕上点一下就放。"
"最实用的是下课能直接关机,不用等学生走完再回去。"),
("信息技术老师 · 试用", "李",
"同一个 WiFi 的时候基本没延迟,看屏幕、传课件都跟本机差不多。"
"机房换网段之后手表也能自己找回来。"),
("教务 · 试用", "张",
"跨校区的时候延迟会高一些,但这个能理解 —— "
"走公网本来就要绕一圈。至少不用为了看一眼屏幕专门跑一趟。"),
]
def _logo(size: int = 26) -> str:
"""内联 SVG 的 logo。
为什么不用 <img src="/favicon.ico">:
ICO 放在 <img> 里各浏览器支持不一致,实测截图里是破图。
内联 SVG 永远能显示,还不用多一个请求。
"""
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", "monitor"),
("手表端", "安卓手表", "APK",
pick(".apk"), "直接安装,不用 root", "Android 7.0+", "watch"),
("服务器端", "一台能上外网的小机器", "压缩包",
pick(".zip", "server"), "只在跨网络时才需要",
"Debian / Ubuntu", "shield"),
]
cards = []
for title, who, tag, item, note, req, icon 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-ico">%s</div>'
'<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>' % (_icon(icon, 22, 1.5), title, who, note, action, req)
)
return "".join(cards)
def _feature_cards() -> str:
out = []
for index, (icon, title, desc) in enumerate(FEATURES):
# reveal 带一点错位延迟,滚动进来时是"依次浮现"而不是整块跳
delay = " style=\"--d:%dms\"" % (index % 3 * 70)
out.append(
'<div class="feat"%s><div class="feat-icon">%s</div>'
'<div class="feat-title">%s</div>'
'<div class="feat-desc">%s</div></div>'
% (delay, _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 _stat_cards() -> str:
out = []
for value, label, note in STATS:
out.append(
'<div class="stat">'
'<div class="stat-v">%s</div>'
'<div class="stat-l">%s</div>'
'<div class="stat-n">%s</div></div>' % (value, label, note)
)
return "".join(out)
def _testimonials() -> str:
out = []
stars = ('<span class="stars" aria-label="5 星">'
+ "\u2605" * 5 + "</span>")
for who, initial, text in TESTIMONIALS:
out.append(
'<figure class="quote">'
'%s'
'<blockquote>%s</blockquote>'
'<figcaption><span class="avatar">%s</span>%s</figcaption>'
'</figure>' % (stars, text, initial, who)
)
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;-webkit-text-size-adjust:100%%}
/* 苹果的字体栈:优先系统 UI 字体,中文落到雅黑/苹方。
字距收紧是"苹果感"最关键的一条 —— 大标题用 -0.03em 左右。 */
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro SC","SF Pro Text",
"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
background:%(BG)s;color:%(TEXT)s;line-height:1.6;
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
a{color:inherit;text-decoration:none}
img,svg{display:block}
.wrap{max-width:1040px;margin:0 auto;padding:0 22px}
.narrow{max-width:760px;margin:0 auto}
/* ---------------- 磨砂吸顶导航(苹果那个味道)----------------
.72 透明 + 22px 模糊 + 180%% 饱和,滚动时下层内容会"透出来但糊掉" */
nav{position:sticky;top:0;z-index:60;
background:rgba(255,255,255,.72);
-webkit-backdrop-filter:saturate(180%%) blur(22px);
backdrop-filter:saturate(180%%) blur(22px);
border-bottom:1px solid rgba(0,0,0,.07)}
.nav-in{display:flex;align-items:center;justify-content:space-between;
height:48px}
.brand{display:flex;align-items:center;gap:8px;font-weight:600;
font-size:15px;letter-spacing:-.01em}
.nav-links{display:flex;gap:26px;font-size:12.5px;color:rgba(0,0,0,.62)}
.nav-links a{transition:color .2s}
.nav-links a:hover{color:%(TEXT)s}
@media(max-width:700px){.nav-links{display:none}}
/* ---------------- Hero ----------------
clamp 让字号在手机上也不至于爆掉。
背后一层极淡的径向渐变当"光",不用图片。 */
.hero{position:relative;padding:104px 0 84px;text-align:center;
overflow:hidden}
.hero::before{content:"";position:absolute;inset:-40%% -20%% auto -20%%;
height:520px;pointer-events:none;
background:radial-gradient(60%% 60%% at 50%% 0%%,
rgba(0,113,227,.10),rgba(0,113,227,0) 70%%)}
.hero>*{position:relative}
.hero h1{font-size:clamp(34px,6.4vw,68px);line-height:1.06;
letter-spacing:-.032em;font-weight:700;margin-bottom:20px}
.hero h1 .grad{background:linear-gradient(90deg,#0a84ff,#3aa0f5 50%%,#64b5f6);
-webkit-background-clip:text;background-clip:text;color:transparent}
.hero .lede{font-size:clamp(16px,2vw,21px);color:%(MUTED)s;
max-width:620px;margin:0 auto 32px;line-height:1.55;
letter-spacing:-.01em}
.cta{display:flex;gap:14px;justify-content:center;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;gap:7px;
padding:12px 26px;border-radius:980px;font-weight:500;font-size:15px;
letter-spacing:-.01em;transition:all .22s cubic-bezier(.4,0,.2,1)}
.btn-p{background:%(ACCENT)s;color:#fff}
.btn-p:hover{background:%(ACCENT_DARK)s;transform:translateY(-1px);
box-shadow:0 8px 22px rgba(0,113,227,.28)}
.btn-s{background:rgba(0,0,0,.05);color:%(TEXT)s}
.btn-s:hover{background:rgba(0,0,0,.09)}
/* 平台行(UU远程那种"支持这些端"的一排) */
.plats{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;
margin-top:34px}
.plat{display:inline-flex;align-items:center;gap:7px;
padding:7px 15px;border-radius:980px;background:%(SURFACE2)s;
color:%(MUTED)s;font-size:13px;font-weight:500}
.plat svg{width:15px;height:15px}
.badge{display:inline-block;margin-top:20px;padding:5px 15px;
border-radius:980px;background:rgba(0,113,227,.08);
color:%(ACCENT)s;font-size:12.5px;font-weight:600}
/* 滚动提示 —— 做明显一点,不然等于没做 */
.scroll-hint{margin:56px auto 0;width:26px;height:42px;
border:2px solid rgba(0,0,0,.22);border-radius:14px;
position:relative}
.scroll-hint::after{content:"";position:absolute;left:50%%;top:8px;
width:4px;height:8px;margin-left:-2px;border-radius:2px;
background:rgba(0,0,0,.42);animation:sd 1.7s ease-in-out infinite}
@keyframes sd{0%%{opacity:0;transform:translateY(0)}
40%%{opacity:1}
80%%{opacity:0;transform:translateY(13px)}
100%%{opacity:0}}
/* ---------------- 通用区段 ---------------- */
section{padding:88px 0}
.alt{background:%(SURFACE2)s}
.sec-head{text-align:center;margin-bottom:52px}
.sec-head h2{font-size:clamp(26px,3.6vw,40px);letter-spacing:-.025em;
font-weight:700;margin-bottom:12px;line-height:1.15}
.sec-head p{color:%(MUTED)s;font-size:clamp(15px,1.7vw,18px);
letter-spacing:-.01em;max-width:560px;margin:0 auto}
/* ---------------- 数据带 ---------------- */
.stats{display:grid;gap:20px;grid-template-columns:1fr}
@media(min-width:680px){.stats{grid-template-columns:repeat(3,1fr)}}
.stat{text-align:center;padding:26px 18px;border-radius:20px;
background:%(BG)s;border:1px solid %(BORDER)s}
.stat-v{font-size:clamp(30px,4.4vw,44px);font-weight:700;
letter-spacing:-.03em;color:%(ACCENT)s;line-height:1.1}
.stat-l{font-weight:600;font-size:14.5px;margin-top:8px}
.stat-n{color:%(FAINT)s;font-size:12.5px;margin-top:4px}
/* ---------------- 功能网格 ----------------
卡片默认无阴影(苹果不爱堆阴影),hover 才浮起来一点 */
.feats{display:grid;gap:16px;grid-template-columns:1fr}
@media(min-width:640px){.feats{grid-template-columns:repeat(2,1fr)}}
@media(min-width:900px){.feats{grid-template-columns:repeat(3,1fr)}}
.feat{background:%(BG)s;border:1px solid %(BORDER)s;border-radius:20px;
padding:28px 24px;transition:transform .28s cubic-bezier(.4,0,.2,1),
box-shadow .28s,border-color .28s}
.feat:hover{transform:translateY(-3px);border-color:transparent;
box-shadow:0 14px 40px rgba(0,0,0,.09)}
.feat-icon{width:42px;height:42px;border-radius:12px;
background:rgba(0,113,227,.08);color:%(ACCENT)s;
display:flex;align-items:center;justify-content:center;
margin-bottom:16px}
.feat-title{font-weight:600;font-size:16.5px;margin-bottom:7px;
letter-spacing:-.015em}
.feat-desc{color:%(MUTED)s;font-size:14px;line-height:1.65}
/* ---------------- 三步 ----------------
中间拉一条渐变线,像"流程" */
.steps{display:grid;gap:18px;position:relative;grid-template-columns:1fr}
@media(min-width:680px){.steps{grid-template-columns:repeat(3,1fr)}}
.step{padding:28px 24px;border:1px solid %(BORDER)s;border-radius:20px;
background:%(BG)s;position:relative}
.step-num{width:32px;height:32px;border-radius:50%%;
background:linear-gradient(135deg,#0071e3,#00a0f0);color:#fff;
display:flex;align-items:center;justify-content:center;
font-weight:600;font-size:14px;margin-bottom:16px}
.step-title{font-weight:600;font-size:16.5px;margin-bottom:7px;
letter-spacing:-.015em}
.step-desc{color:%(MUTED)s;font-size:14px;line-height:1.65}
/* ---------------- 用户评价 ---------------- */
.quotes{display:grid;gap:18px;grid-template-columns:1fr}
@media(min-width:760px){.quotes{grid-template-columns:repeat(3,1fr)}}
.quote{background:%(BG)s;border:1px solid %(BORDER)s;border-radius:20px;
padding:26px 24px;display:flex;flex-direction:column}
.stars{color:#f5a623;letter-spacing:2px;font-size:13px;margin-bottom:12px}
.quote blockquote{font-size:14.5px;line-height:1.7;color:%(TEXT)s;
flex:1;margin-bottom:18px}
.quote figcaption{display:flex;align-items:center;gap:10px;
color:%(MUTED)s;font-size:13px}
.avatar{width:30px;height:30px;border-radius:50%%;background:%(SURFACE2)s;
color:%(MUTED)s;display:flex;align-items:center;justify-content:center;
font-weight:600;font-size:13px;flex:none}
/* ---------------- 下载 ---------------- */
.dls{display:grid;gap:18px;grid-template-columns:1fr}
@media(min-width:760px){.dls{grid-template-columns:repeat(3,1fr)}}
.dl-card{border:1px solid %(BORDER)s;border-radius:20px;padding:26px 22px;
display:flex;flex-direction:column;background:%(BG)s;
transition:transform .28s cubic-bezier(.4,0,.2,1),box-shadow .28s,
border-color .28s}
.dl-card:hover{transform:translateY(-3px);border-color:transparent;
box-shadow:0 14px 40px rgba(0,0,0,.09)}
.dl-ico{width:38px;height:38px;border-radius:11px;background:%(SURFACE2)s;
color:%(TEXT)s;display:flex;align-items:center;justify-content:center;
margin-bottom:16px}
.dl-head{display:flex;align-items:baseline;gap:8px;margin-bottom:5px}
.dl-tag{font-weight:600;font-size:17px;letter-spacing:-.02em}
.dl-who{color:%(FAINT)s;font-size:12.5px}
.dl-note{color:%(MUTED)s;font-size:13.5px;margin-bottom:20px;flex:1;
line-height:1.6}
.dl-btn{display:block;text-align:center;background:%(ACCENT)s;color:#fff;
padding:11px 16px;border-radius:980px;font-weight:500;font-size:14.5px;
transition:background .2s}
.dl-btn:hover{background:%(ACCENT_DARK)s}
.dl-file{color:%(FAINT)s;font-size:11px;margin-top:10px;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:980px}
.dl-req{color:%(FAINT)s;font-size:12px;margin-top:13px;text-align:center}
/* ---------------- FAQ ---------------- */
.faqs{max-width:720px;margin:0 auto}
.faq{border-bottom:1px solid %(BORDER)s}
.faq summary{padding:22px 0;font-weight:600;font-size:16.5px;
cursor:pointer;list-style:none;display:flex;
justify-content:space-between;align-items:center;gap:16px;
letter-spacing:-.015em;transition:color .2s}
.faq summary:hover{color:%(ACCENT)s}
.faq summary::-webkit-details-marker{display:none}
.faq-mark{width:8px;height:8px;border-right:1.8px solid %(FAINT)s;
border-bottom:1.8px solid %(FAINT)s;transform:rotate(45deg);
transition:transform .25s;flex:none;margin-right:5px}
.faq[open] .faq-mark{transform:rotate(-135deg)}
.faq-body{color:%(MUTED)s;font-size:15px;padding:0 0 24px;line-height:1.75}
/* ---------------- 收尾 CTA ---------------- */
.endcta{text-align:center;padding:96px 0}
.endcta h2{font-size:clamp(26px,4.4vw,44px);letter-spacing:-.03em;
font-weight:700;margin-bottom:14px}
.endcta p{color:%(MUTED)s;font-size:17px;margin-bottom:30px}
/* ---------------- 页脚 ---------------- */
footer{padding:44px 0 60px;border-top:1px solid %(BORDER)s;
color:%(FAINT)s;font-size:12.5px;background:%(SURFACE2)s}
.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{transition:color .2s}
.foot-links a:hover{color:%(TEXT)s}
.copy{margin-top:22px;text-align:center;color:%(FAINT)s}
/* ---------------- 滚动淡入 ----------------
⚠ 关键:默认必须是**可见**的(没有 .reveal 的降级路径),
否则脚本一旦没跑起来(旧浏览器/被拦截),整页就是空白。
所以 .reveal 只在 JS 确认可用之后才由脚本加上。 */
.reveal{opacity:0;transform:translateY(22px);
transition:opacity .7s cubic-bezier(.4,0,.2,1),
transform .7s cubic-bezier(.4,0,.2,1);
transition-delay:var(--d,0ms)}
.reveal.in{opacity:1;transform:none}
/* 关掉动效(系统设置里开了"减少动态效果",或者浏览器太老) */
@media(prefers-reduced-motion:reduce){
*{animation:none!important;transition:none!important}
.reveal{opacity:1!important;transform:none!important}
}
""" % {"BG": BG, "SURFACE": SURFACE, "SURFACE2": SURFACE2, "BORDER": BORDER,
"TEXT": TEXT, "MUTED": MUTED, "FAINT": FAINT,
"ACCENT": ACCENT, "ACCENT_DARK": ACCENT_DARK}
# 产品展示那段的样式在 showcase.py 里,它自己已经格式化好了,
# 这里直接拼 —— **不能再套一层 %**。
# (踩过的坑:写成了 `""" + showcase.SHOWCASE_CSS % {...}`,
# % 优先级高于 +,于是把已经格式化过的 CSS 又格式化一遍,
# 报 "unsupported format character ';'" 直接把官网打挂。)
SITE_CSS = SITE_CSS + showcase.SHOWCASE_CSS
SITE_JS = """
// ---------------- 滚动淡入 ----------------
//
// ⚠ 顺序很重要:先把 .reveal 加上(这一步之后元素才是隐藏的),
// 再注册观察器。如果反过来,脚本报错时元素会被 CSS 永久隐藏。
// 所以整个东西包在 try 里,出错就什么都不加 —— 页面照常显示。
(function () {
if (!('IntersectionObserver' in window)) return; // 老浏览器:不做动画
try {
var reduce = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return; // 用户要求别动
var items = document.querySelectorAll(
'.feat, .step, .stat, .quote, .dl-card');
if (!items.length) return;
for (var i = 0; i < items.length; i++) items[i].classList.add('reveal');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
if (en.isIntersecting) {
en.target.classList.add('in');
io.unobserve(en.target);
}
});
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
for (var j = 0; j < items.length; j++) io.observe(items[j]);
// 兜底:1.2 秒后还没进视口的也显示出来(防止观察器失灵留下空白)
setTimeout(function () {
for (var k = 0; k < items.length; k++) items[k].classList.add('in');
}, 1200);
} catch (e) { /* 出错就不动,页面保持可见 */ }
})();
// ---------------- 锚点平滑滚动 ----------------
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'}); }
});
});
"""
def render_site(files: list) -> str:
"""渲染官网首页。files 是 _list_downloads() 的结果。"""
version = _version(files)
badge = ("最新版 " + version) if version else ""
plats = "".join(
'<span class="plat">%s%s</span>' % (_icon(name, 15, 1.7), label)
for name, label in (("monitor", "Windows 电脑"),
("watch", "安卓手表"),
("shield", "跨网络中转"))
)
return ("""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="%(BG)s">
<title>ykxmz · 用手表管教室那台电脑</title>
<meta name="description" content="\u8001\u5e08\u7528\u624b\u8868\u7ba1\u6559\u5ba4\u91cc\u90a3\u53f0\u7535\u8111\uff1a\u653e\u8bfe\u4ef6\u3001\u6295\u56fe\u3001\u4e0b\u8bfe\u5173\u673a\uff0c\u4e0d\u7528\u8d70\u56de\u8bb2\u53f0\u3002\u540c WiFi \u65f6\u6beb\u79d2\u7ea7\u76f4\u8fde\uff0c\u4e0d\u7ecf\u670d\u52a1\u5668\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><span class="grad">就能管教室那台电脑</span></h1>
<p class="lede">放课件、放音频、投图到大屏、看看有没有人在跑无关程序、
下课关机 —— 不用走回讲台去点鼠标。</p>
<div class="cta">
<a class="btn btn-p" href="#download">下载安装</a>
<a class="btn btn-s" href="#how">先看看怎么用</a>
</div>
<div class="plats">%(plats)s</div>
%(badge)s
<div class="scroll-hint" aria-hidden="true"></div>
</div>
<section class="alt" id="numbers"><div class="wrap">
<div class="sec-head">
<h2>能直连就一定直连</h2>
<p>手表和电脑在同一个 WiFi 时不绕服务器 ——
这是毫秒级、不耗流量、也不怕隧道抖的那条路</p>
</div>
<div class="stats">%(stats)s</div>
</div></section>
<section id="features"><div class="wrap">
<div class="sec-head">
<h2>上课用得上的,基本都在这</h2>
<p>手表上十个分页,常用操作都在第一屏</p>
</div>
<div class="feats">%(feats)s</div>
</div></section>
<section class="alt" 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="voices"><div class="wrap">
<div class="sec-head">
<h2>试过的老师怎么说</h2>
<p>教学场景下的试用反馈</p>
</div>
<div class="quotes">%(quotes)s</div>
</div></section>
<section class="alt" 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>
<div class="endcta"><div class="wrap">
<h2>装一次,以后就不用管了</h2>
<p>配对一次之后,手表打开就自动连</p>
<a class="btn btn-p" href="#download">下载安装</a>
</div></div>
<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>%(js)s</script>
</body>
</html>""" % {
"css": SITE_CSS,
"js": SITE_JS,
"BG": BG,
"plats": plats,
"badge": '<div class="badge">%s</div>' % badge if badge else "",
"stats": _stat_cards(),
"feats": _feature_cards(),
"steps": _step_cards(),
"showcase": showcase.render_showcase(),
"quotes": _testimonials(),
"dls": _download_cards(files),
"faq": _faq_items(),
"dlsub": ("当前版本 " + version) if version else "选择你的设备",
}).replace("__LOGO__", _logo())