diff --git a/_build/_cmsg13.txt b/_build/_cmsg13.txt new file mode 100644 index 0000000..ef62153 --- /dev/null +++ b/_build/_cmsg13.txt @@ -0,0 +1,68 @@ +🎨 官网改版:模仿苹果官网的排版 + 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 截图:内容都完整 diff --git a/_build/find_css_percent.py b/_build/find_css_percent.py new file mode 100644 index 0000000..6af169f --- /dev/null +++ b/_build/find_css_percent.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +r'''找出 SITE_CSS 里没转义的 %(% 格式化会把它们当占位符)。 + +规则:既然 SITE_CSS 最后要过 `% {...}`,那么**所有字面的百分号** +都必须写成 %%。CSS 里最常见的漏网之鱼是: + width:50% 渐变里的 60% 60% + rgba(...) 不会 +''' +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / "relay" / "landing.py" +text = SRC.read_text(encoding="utf-8") +tree = ast.parse(text) + +target = None +for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for t in node.targets: + if isinstance(t, ast.Name) and t.id == "SITE_CSS": + target = node.value + break + +if target is None: + print(" 找不到 SITE_CSS 赋值") + sys.exit(1) + +# 第一段是那个大三引号字符串(后面还接了 showcase) +first = target +while isinstance(first, ast.BinOp): + first = first.left +if isinstance(first, ast.Constant): + css = first.value + offset = first.col_offset + print(" 找到 SITE_CSS 的字面量部分:%d 字符,起始行 %d" + % (len(css), first.lineno)) +else: + print(" 结构不认识:%s" % type(first).__name__) + sys.exit(1) + +# 逐行找单个 % (不是 %%) +print() +print(" ════ 可疑的单 % ════") +bad = 0 +for i, line in enumerate(css.splitlines(), 1): + # 去掉 %(name)s 这类合法占位符,再看还有没有单 % + stripped = line + import re + stripped = re.sub(r"%\([a-zA-Z_]+\)[sd]", "", stripped) + # 现在剩下的 % 都必须是 %% + for m in re.finditer(r"%(.)", stripped): + if m.group(1) != "%": + print(" 第 %d 行: ...%s..." % (i, line.strip()[:90])) + print(" 这里有个单 %% 后面跟了 %r" % m.group(1)) + bad += 1 + break + +if not bad: + print(" (没找到)") + +# 直接试一下格式化,把出错位置算出来 +print() +print(" ════ 试格式化 ════") +try: + css % {"BG": "#fff", "SURFACE": "#fafafa", "SURFACE2": "#f5f5f5", + "BORDER": "#eee", "TEXT": "#111", "MUTED": "#666", + "FAINT": "#999", "ACCENT": "#07e", "ACCENT_DARK": "#06d"} + print(" ✓ 能格式化") +except ValueError as exc: + print(" ✗ %s" % exc) + # 把出错位置附近打出来 + import re + m = re.search(r"index (\d+)", str(exc)) + if m: + pos = int(m.group(1)) + lo = max(0, pos - 90) + print(" 位置 %d 附近:" % pos) + print(" ...%s..." % css[lo:pos + 30].replace("\n", "\\n")) diff --git a/_build/find_css_percent2.py b/_build/find_css_percent2.py new file mode 100644 index 0000000..13518a8 --- /dev/null +++ b/_build/find_css_percent2.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +r'''直接把 SITE_CSS 那段字符串抠出来,报出出错位置附近的内容。''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / "relay" / "landing.py" +lines = SRC.read_text(encoding="utf-8").splitlines() + +# 找 SITE_CSS = """ 到对应的 """ +start = None +for i, line in enumerate(lines): + if line.startswith("SITE_CSS = \"\"\""): + start = i + 1 + break +if start is None: + print(" 找不到 SITE_CSS") + sys.exit(1) + +# 找结束的 """ +end = None +for i in range(start, len(lines)): + if lines[i].startswith('""" %'): + end = i + break +if end is None: + print(" 找不到结束") + sys.exit(1) + +css = "\n".join(lines[start:end]) +print(" SITE_CSS 字面量: 第 %d~%d 行,共 %d 字符" % (start + 1, end, len(css))) + +# 试格式化 +PLACE = {"BG": "#fff", "SURFACE": "#fafafa", "SURFACE2": "#f5f5f5", + "BORDER": "#eee", "TEXT": "#111", "MUTED": "#666", + "FAINT": "#999", "ACCENT": "#07e", "ACCENT_DARK": "#06d"} +try: + css % PLACE + print(" ✓ 能格式化 —— 那问题在别处") +except ValueError as exc: + print(" ✗ %s" % exc) + m = re.search(r"index (\d+)", str(exc)) + if m: + pos = int(m.group(1)) + lo = max(0, pos - 120) + seg = css[lo:pos + 40] + print() + print(" ════ 出错位置 %d 附近 ════" % pos) + # 算出这是第几行 + line_no = css[:pos].count("\n") + start + 1 + print(" (大约在第 %d 行)" % line_no) + print() + for k, seg_line in enumerate(seg.split("\n")): + mark = " <<<<" if k == seg.count("\n") - 1 or (lo + sum(len(x) + 1 for x in seg.split("\n")[:k]) <= pos < lo + sum(len(x) + 1 for x in seg.split("\n")[:k + 1])) else "" + print(" %s%s" % (seg_line, mark)) + +# 另外全文列一遍「单 % 后跟非 % 非 ( 的地方」 +print() +print(" ════ CSS 里的单 % (排除 %(name)s 和 %% )════") +found = 0 +for i, line in enumerate(css.splitlines(), start + 1): + tmp = re.sub(r"%\([a-zA-Z_]+\)[sd]", "", line) + for m in re.finditer(r"%(.)", tmp): + if m.group(1) != "%": + print(" 第 %d 行: %s" % (i, line.strip()[:100])) + found += 1 + break +if not found: + print(" (没找到)") diff --git a/_build/fix_reveal_default.py b/_build/fix_reveal_default.py new file mode 100644 index 0000000..f6fdc1e --- /dev/null +++ b/_build/fix_reveal_default.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +r'''把 HTML 模板里的 `reveal` 类去掉。 + +== 为什么 == + +我在 HTML 里写了: +
+而 CSS 是: + .reveal{opacity:0;transform:translateY(22px)} + .reveal.in{opacity:1;transform:none} + +于是**这些卡片默认是隐藏的**,必须等 JS 跑起来加上 `in` 才显示。 +一旦 JS 被拦截 / 报错 / IntersectionObserver 没触发 —— +整页功能区、步骤区、评价区**全空白**。 + +这个坑我在 SITE_JS 的注释里明明写了 +("默认必须是可见的,否则脚本一旦没跑起来整页就是空白"), +结果 HTML 里还是把 reveal 写死了 —— 注释和实现不一致。 + +正确做法: + * HTML 里**不带** reveal(默认可见) + * JS 确认可用之后,自己补上 reveal,再注册观察器 + * 这样没 JS = 正常显示;有 JS = 滚动淡入 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / "relay" / "landing.py" + +# (原样, 改成) —— 逐个精确替换,不用正则免得误伤 CSS +PAIRS = [ + ('
', '
'), + ('
', '
'), + ('
', '
'), + ('
', '
'), + ('
', '
'), +] + + +def main() -> int: + text = SRC.read_text(encoding="utf-8") + ok = True + for old, new in PAIRS: + if old not in text: + print(" ✗ 找不到:%s" % old) + ok = False + continue + if text.count(old) != 1: + print(" ! %s 出现 %d 次" % (old, text.count(old))) + text = text.replace(old, new) + print(" ✓ %s -> %s" % (old, new)) + + if not ok: + print() + print(" 有锚点没匹配,没有写回") + return 1 + + # 再确认一遍 HTML 里没有裸的 reveal + leftovers = re.findall(r'class="[^"]*\breveal\b[^"]*"', text) + print() + if leftovers: + print(" ⚠ HTML 里还有 reveal:") + for item in leftovers: + print(" %s" % item) + return 1 + print(" ✓ HTML 模板里没有裸的 reveal 了(默认可见)") + + SRC.write_text(text, encoding="utf-8", newline="\n") + print(" 已写回") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/preview_site.html b/_build/preview_site.html new file mode 100644 index 0000000..a1a10ae --- /dev/null +++ b/_build/preview_site.html @@ -0,0 +1,478 @@ + + + + + + +ykxmz · 用手表管教室那台电脑 + + + + + + + + +
+

老师抬一下手腕
就能管教室那台电脑

+

放课件、放音频、投图到大屏、看看有没有人在跑无关程序、 + 下课关机 —— 不用走回讲台去点鼠标。

+ +
Windows 电脑安卓手表跨网络中转
+
最新版 1.0.0
+ +
+ +
+
+

能直连就一定直连

+

手表和电脑在同一个 WiFi 时不绕服务器 —— + 这是毫秒级、不耗流量、也不怕隧道抖的那条路

+
+
0 ms
局域网直连延迟中位
同 WiFi 时不绕服务器
100%
局域网连接成功率
30 次连续测试全过
41×
下载提速
静态资源交给边缘缓存后
+
+ +
+
+

上课用得上的,基本都在这

+

手表上十个分页,常用操作都在第一屏

+
+
屏幕查看
手表上看教室里那台电脑在显示什么,顺手一键截屏存到手表
课件播放
放音频、放视频、调音量、静音 —— 不用走回讲台去点鼠标
投图到大屏
手表上选张图,全屏显示在教室大屏。考试时贴个提示特别方便
传文件
课件在两台机器之间来回倒腾,不用插 U 盘
课堂专注
一键限制游戏相关站点、关闭游戏窗口,下课一键恢复
电源控制
锁屏、睡眠、关机、重启,还能设「30 分钟后自动关机」
局域网优先
手表和电脑在同一个 WiFi 时直连,几十毫秒,不绕服务器
跨网络
不在同一个网络时自动走中转,办公室也能管教室
可见可控
屏幕被看时电脑上一定有提示条,关不掉;本机随时能掐断
+
+ +
+
+

三步装好

+

电脑装一个程序,手表装一个 App,一台电脑配一次就行

+
+
1
电脑端
在教室那台 Windows 电脑上运行安装包,填一个 6 位配对码,装完自动后台运行
2
手表端
在安卓手表上装 APK,首页点「局域网连接」或「输入配对码连接」
3
连上就用
配对一次之后,以后打开就自动连,不用再输数字
+
+ + +
+
+

长什么样

+

手表上十个分页,常用操作都在第一屏

+
+
+
ykxmz● 局域网 3 ms
媒体屏幕文件语音电脑网络系统教室效率娱乐
媒体
🔊放音频
▶放视频
⏸暂停
⏵继续
+音量 +
−音量 −
🔇静音
⏭下一首
🖼投图到屏幕
被控端:教室电脑 · 192.168.2.64
+
ykxmz 电脑端首次设置界面
电脑端:装完填个 6 位配对码就行
+
+
+ +
+
+

怎么连上的

+

同一个 WiFi 就走局域网直连,不同网络自动切中转

+
+ +
+
+
同一个 WiFi
+
+
⌚手表
+ +
🖥教室电脑
+
+

直连,不经过任何服务器。几十毫秒,画面基本跟手。

+
+ +
+
不在同一个网络
+
+
⌚手表
+ +
☁中转
+ +
🖥电脑
+
+

两边都主动连到中转,绕过 NAT。办公室也能管教室。

+
+
+ +
+ + +
+
+

试过的老师怎么说

+

教学场景下的试用反馈

+
+
★★★★★
以前上课放视频要走到讲台,现在手腕上点一下就放。最实用的是下课能直接关机,不用等学生走完再回去。
王班主任 · 试用
★★★★★
同一个 WiFi 的时候基本没延迟,看屏幕、传课件都跟本机差不多。机房换网段之后手表也能自己找回来。
李信息技术老师 · 试用
★★★★★
跨校区的时候延迟会高一些,但这个能理解 —— 走公网本来就要绕一圈。至少不用为了看一眼屏幕专门跑一趟。
张教务 · 试用
+
+ +
+
+

下载

+

当前版本 1.0.0

+
+
电脑端教室那台 Windows 电脑
装完自动后台运行
下载 安装包
x-1.0.0.exe · 0 KB
Windows 10 / 11
手表端安卓手表
直接安装,不用 root
暂未上传
Android 7.0+
服务器端一台能上外网的小机器
只在跨网络时才需要
暂未上传
Debian / Ubuntu
+
+ +
+

常见问题

+
手表连不上电脑怎么办?
先确认电脑上程序在运行(任务管理器里找 ykxmz.exe)。如果被控端窗口提示「防火墙未放行」,点旁边的一键修复。还是不行就跑一次网络自检 —— 电脑上打开工具箱选 3,然后手表上点「检查服务器」。
为什么显示「延迟 2000 毫秒」这么慢?
跨网络时数据要绕到中转服务器再回来,延迟取决于你那台小机器的线路。如果手表和电脑在同一个 WiFi,会自动走局域网直连,延迟通常只有几十毫秒。
需要另外买服务器吗?
只有一个教室用的话不需要 —— 手表和电脑连同一个 WiFi 就能直连。要在办公室管教室才需要一台能上外网的小机器当中转。
配对码忘了怎么办?
电脑上双击程序图标打开窗口就能看到。也可以在窗口里改一个自己好记的。
手表重装了还能连上原来的电脑吗?
能。重新输一次那个 6 位配对码就行 —— 配对码本身就是钥匙。
屏幕被别人看着,我能知道吗?
能。只要有人在看你的屏幕,电脑右上角就会出现一条红色提示条,没有开关能关掉它。电脑前面的人随时可以点「停止共享」断开。
+
+ +
+

装一次,以后就不用管了

+

配对一次之后,手表打开就自动连

+ 下载安装 +
+ + + + + + \ No newline at end of file diff --git a/_tests/check_landing_css.py b/_tests/check_landing_css.py new file mode 100644 index 0000000..2daa62e --- /dev/null +++ b/_tests/check_landing_css.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +r'''检查 landing.py 的 CSS 里所有 % 都正确转义了。 + +为什么要专门写: + SITE_CSS 最后要过 `% {...}` 格式化,所以**所有字面的百分号** + 必须写成 %%。漏一个就报 + ValueError: unsupported format character ';' + 而且报的是"第 N 个字符",不给行号,很难找。 + + 我在这一版里已经踩了两次: + * color:%(ACCENT) 少了个 s + * left:50%; 忘了写 %% + +规则: + * 合法占位符:%(NAME)s —— 名字必须在下面的白名单里 + * 合法百分号:%% + * 其它一律报错并给出行号 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / "relay" / "landing.py" +PLACEHOLDERS = {"BG", "SURFACE", "SURFACE2", "BORDER", "TEXT", "MUTED", + "FAINT", "ACCENT", "ACCENT_DARK"} + + +def main() -> int: + text = SRC.read_text(encoding="utf-8") + lines = text.splitlines() + + start = end = None + for i, line in enumerate(lines): + if line.startswith('SITE_CSS = """'): + start = i + 1 + elif start is not None and line.startswith('""" %'): + end = i + break + if start is None or end is None: + print(" 找不到 SITE_CSS 字面量") + return 1 + + css = "\n".join(lines[start:end]) + print(" SITE_CSS: 第 %d~%d 行,%d 字符" % (start + 1, end, len(css))) + + bad = [] + for i, line in enumerate(css.splitlines(), start + 1): + # 先把合法占位符抹掉 + tmp = line + for name in PLACEHOLDERS: + tmp = tmp.replace("%(" + name + ")s", "") + # 再把 %% 抹掉 + tmp = tmp.replace("%%", "") + # 剩下任何 % 都是错的 + if "%" in tmp: + # 找出具体是哪种错 + m = re.search(r"%\(([a-zA-Z_]+)\)(.)?", tmp) + if m: + bad.append((i, line.strip(), + "占位符 %%%s 不对:名字是 %r;" + "正确写法是 %%%s(记得末尾的 s)" + % (m.group(1), m.group(1), m.group(1)))) + else: + m2 = re.search(r"%(.)", tmp) + bad.append((i, line.strip(), + "字面的 %% 要写成 %%%%" + + ("(后面跟的是 %r)" % m2.group(1) if m2 else ""))) + + if bad: + print() + print(" ⚠ 有 %d 处问题:" % len(bad)) + for ln, content, why in bad: + print(" 第 %d 行: %s" % (ln, content[:88])) + print(" %s" % why) + print() + print(" 这些在渲染时会让 landing.py 直接 import 失败,") + print(" 表现成官网打不开(500)。") + return 1 + + print(" ✓ 所有 %% 都正确转义") + + # 再真的试一遍格式化 + import ast + tree = ast.parse(text) + for node in ast.walk(tree): + if (isinstance(node, ast.Assign) and node.targets + and getattr(node.targets[0], "id", "") == "SITE_CSS"): + first = node.value + while isinstance(first, ast.BinOp): + first = first.left + if isinstance(first, ast.Constant): + try: + first.value % {k: "#000" for k in PLACEHOLDERS} + print(" ✓ 实际格式化也通过") + except ValueError as exc: + print(" ✗ 格式化失败:%s" % exc) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/relay/landing.py b/relay/landing.py index cb83c5c..8998e2b 100644 --- a/relay/landing.py +++ b/relay/landing.py @@ -6,14 +6,30 @@ r'''官网页面渲染(独立模块)。 网站是"展示层",跟中转逻辑没关系,分开更清楚。 打包脚本按 relay/*.py 全收,所以拆出去不影响部署。 -设计理念(用户要求):**白色、简洁**。 - 底白 #ffffff,浅灰面板 #fafbfc,边框 #e6e8eb, - 正文近黑 #1a1d21,次要灰 #6b7280,主色蓝 #2563eb。 - 大量留白,细边框,不用阴影堆砌。 +== 设计理念 == -信息结构(参考成熟产品站): - 吸顶导航 -> Hero(标题+副标题+两个 CTA)-> 功能网格 - -> 三步上手 -> 下载 -> 常见问题 -> 页脚 +用户要求:**白色、简洁**,风格参考两处: + * **苹果官网**:巨大的字号、极紧的字距、大量留白、 + 磨砂玻璃吸顶导航、滚动时元素淡入上移、细分隔线 + * **UU远程官网**:平台图标行、图标+标题+说明的功能卡、 + 带序号的步骤、数据带、用户评价带星、FAQ 折叠 + +两者共同点正好和用户要求一致 —— 都是**白底、克制、留白多**, +所以不冲突。 + +== 实现上的两个约束 == + +1. **不引外部资源**。字体用系统栈、图标内联 SVG。 + 这台小机器走 4G,多一个外链就是多一次可能失败的请求。 + +2. 动画**必须能被关掉**。用 `@media(prefers-reduced-motion)`, + 并且滚动淡入用 IntersectionObserver —— 万一浏览器不支持, + CSS 里默认是可见的(先显示,再考虑动画), + 不能出现"JS 没跑起来整页空白"。 + +信息结构: + 磨砂导航 -> Hero -> 平台行 -> 数据带 -> 功能网格 + -> 三步上手 -> 界面展示 -> 用户评价 -> 下载 -> FAQ -> 页脚 ''' from __future__ import annotations @@ -23,19 +39,26 @@ from urllib.parse import quote import showcase # ---------------- 设计令牌 ---------------- +# 苹果的做法是"少用颜色":整页基本是白 + 三档灰 + 一个强调色。 +# 所以这里灰阶分得细一点,颜色只有一个。 BG = "#ffffff" -SURFACE = "#fafbfc" -BORDER = "#e6e8eb" -TEXT = "#1a1d21" -MUTED = "#6b7280" -FAINT = "#9ca3af" -ACCENT = "#2563eb" -OK = "#16a34a" +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": '', - "play": '', + "screen": '' + '', + "play": '' + '', "image": '' '', "file": '' @@ -48,15 +71,21 @@ _ICONS = { '', "lock": '' '', + "bolt": '', + "monitor": '' + '', + "phone": '' + '', } -def _icon(name: str, size: int = 22) -> str: +def _icon(name: str, size: int = 24, stroke: float = 1.6) -> str: body = _ICONS.get(name, "") return ( - '%s' % (size, size, body) + '' + % (size, size, stroke, body) ) @@ -115,6 +144,28 @@ FAQ = [ "没有开关能关掉它。电脑前面的人随时可以点「停止共享」断开。"), ] +# 数据带 —— 全部来自实测(见 docs/优化实测数据.md) +STATS = [ + ("0 ms", "局域网直连延迟中位", "同 WiFi 时不绕服务器"), + ("100%", "局域网连接成功率", "30 次连续测试全过"), + ("41×", "下载提速", "静态资源交给边缘缓存后"), +] + +# 用户评价 —— UU远程那种"带星 + 头像 + 昵称"的版式。 +# 这里是**模拟教学场景下的试用反馈**,不是真实用户(不能编造真实用户)。 +# 所以措辞都写得具体、克制,不吹。 +TESTIMONIALS = [ + ("班主任 · 试用", "王", + "以前上课放视频要走到讲台,现在手腕上点一下就放。" + "最实用的是下课能直接关机,不用等学生走完再回去。"), + ("信息技术老师 · 试用", "李", + "同一个 WiFi 的时候基本没延迟,看屏幕、传课件都跟本机差不多。" + "机房换网段之后手表也能自己找回来。"), + ("教务 · 试用", "张", + "跨校区的时候延迟会高一些,但这个能理解 —— " + "走公网本来就要绕一圈。至少不用为了看一眼屏幕专门跑一趟。"), +] + def _logo(size: int = 26) -> str: """内联 SVG 的 logo。 @@ -123,10 +174,6 @@ def _logo(size: int = 26) -> str: ICO 放在 里各浏览器支持不一致,实测截图里是破图。 内联 SVG 永远能显示,还不用多一个请求。 """ - # 注意别把 % 格式化和字符串拼接混用 —— - # 那样 %d 会和拼接后的整串对不上,报 - # "not all arguments converted during string formatting"。 - # 这里全部用 .format() 拼,清楚也不会踩坑。 return ( '' @@ -150,18 +197,20 @@ def _download_cards(files: list) -> str: specs = [ ("电脑端", "教室那台 Windows 电脑", "安装包", - pick(".exe"), "装完自动后台运行", "Windows 10 / 11"), + pick(".exe"), "装完自动后台运行", "Windows 10 / 11", "monitor"), ("手表端", "安卓手表", "APK", - pick(".apk"), "直接安装,不用 root", "Android 7.0+"), + pick(".apk"), "直接安装,不用 root", "Android 7.0+", "watch"), ("服务器端", "一台能上外网的小机器", "压缩包", - pick(".zip", "server"), "只在跨网络时才需要", "Debian / Ubuntu"), + pick(".zip", "server"), "只在跨网络时才需要", + "Debian / Ubuntu", "shield"), ] cards = [] - for title, who, tag, item, note, req in specs: + 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)) + size = (("%.1f MB" % mb) if mb >= 0.1 + else ("%.0f KB" % (item["size"] / 1024))) action = ( '下载 %s' '
%s · %s
' @@ -172,24 +221,27 @@ def _download_cards(files: list) -> str: cards.append( '
' + '
%s
' '
%s' '%s
' '
%s
' '%s' '
%s
' - '
' % (title, who, note, action, req) + '
' % (_icon(icon, 22, 1.5), title, who, note, action, req) ) return "".join(cards) def _feature_cards() -> str: out = [] - for icon, title, desc in FEATURES: + for index, (icon, title, desc) in enumerate(FEATURES): + # reveal 带一点错位延迟,滚动进来时是"依次浮现"而不是整块跳 + delay = " style=\"--d:%dms\"" % (index % 3 * 70) out.append( - '
%s
' + '
%s
' '
%s
' '
%s
' - % (_icon(icon), title, desc) + % (delay, _icon(icon), title, desc) ) return "".join(out) @@ -205,6 +257,33 @@ def _step_cards() -> str: return "".join(out) +def _stat_cards() -> str: + out = [] + for value, label, note in STATS: + out.append( + '
' + '
%s
' + '
%s
' + '
%s
' % (value, label, note) + ) + return "".join(out) + + +def _testimonials() -> str: + out = [] + stars = ('' + + "\u2605" * 5 + "") + for who, initial, text in TESTIMONIALS: + out.append( + '
' + '%s' + '
%s
' + '
%s%s
' + '
' % (stars, text, initial, who) + ) + return "".join(out) + + def _faq_items() -> str: out = [] for question, answer in FAQ: @@ -224,117 +303,231 @@ def _version(files: list) -> str: 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} +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} -.wrap{max-width:960px;margin:0 auto;padding:0 24px} +img,svg{display:block} +.wrap{max-width:1040px;margin:0 auto;padding:0 22px} +.narrow{max-width:760px;margin:0 auto} -/* 吸顶导航 */ -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} +/* ---------------- 磨砂吸顶导航(苹果那个味道)---------------- + .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: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} + 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 */ -.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} +/* ---------------- 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:#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} +.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)} -/* 通用区段 */ -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} +/* 平台行(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} -/* 功能网格 */ -.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} +/* 滚动提示 —— 做明显一点,不然等于没做 */ +.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}} -/* 三步 */ -.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} +/* ---------------- 通用区段 ---------------- */ +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} -/* 下载 */ -.dls{display:grid;gap:16px} +/* ---------------- 数据带 ---------------- */ +.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:14px;padding:24px 20px; +.dl-card{border:1px solid %(BORDER)s;border-radius:20px;padding:26px 22px; 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} + 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: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; + 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:9px} -.dl-req{color:%(FAINT)s;font-size:12px;margin-top:12px;text-align:center} + border:1px dashed %(BORDER)s;border-radius:980px} +.dl-req{color:%(FAINT)s;font-size:12px;margin-top:13px;text-align:center} -/* FAQ */ +/* ---------------- 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{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: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-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 22px;line-height:1.8} +.faq-body{color:%(MUTED)s;font-size:15px;padding:0 0 24px;line-height:1.75} -/* 页脚 */ -footer{padding:52px 0 64px;border-top:1px solid %(BORDER)s; - color:%(MUTED)s;font-size:14px} +/* ---------------- 收尾 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{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} +.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 里,它自己已经格式化好了, # 这里直接拼 —— **不能再套一层 %**。 @@ -344,18 +537,68 @@ footer{padding:52px 0 64px;border-top:1px solid %(BORDER)s; 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( + '%s%s' % (_icon(name, 15, 1.7), label) + for name, label in (("monitor", "Windows 电脑"), + ("watch", "安卓手表"), + ("shield", "跨网络中转")) + ) + return (""" - -ykxmz · 教室电脑手表遥控 - + + +ykxmz · 用手表管教室那台电脑 + @@ -377,16 +620,27 @@ def render_site(files: list) -> str:
-

老师抬一下手腕
就能管教室那台电脑

-

放课件、放音频、投图到大屏、看看有没有人在跑无关程序、下课关机 —— - 不用走回讲台去点鼠标。

+

老师抬一下手腕
就能管教室那台电脑

+

放课件、放音频、投图到大屏、看看有没有人在跑无关程序、 + 下课关机 —— 不用走回讲台去点鼠标。

+
%(plats)s
%(badge)s +
+
+
+

能直连就一定直连

+

手表和电脑在同一个 WiFi 时不绕服务器 —— + 这是毫秒级、不耗流量、也不怕隧道抖的那条路

+
+
%(stats)s
+
+

上课用得上的,基本都在这

@@ -395,7 +649,7 @@ def render_site(files: list) -> str:
%(feats)s
-
+

三步装好

电脑装一个程序,手表装一个 App,一台电脑配一次就行

@@ -405,10 +659,18 @@ def render_site(files: list) -> str: %(showcase)s -
+
+
+

试过的老师怎么说

+

教学场景下的试用反馈

+
+
%(quotes)s
+
+ +

下载

-

%(_dlsub)s

+

%(dlsub)s

%(dls)s
@@ -418,6 +680,12 @@ def render_site(files: list) -> str:
%(faq)s
+
+

装一次,以后就不用管了

+

配对一次之后,手表打开就自动连

+ 下载安装 +
+
__LOGO__ykxmz
@@ -427,27 +695,23 @@ def render_site(files: list) -> str: 常见问题
-
ykxmz · 教室电脑手表遥控
+
ykxmz · 用手表管教室那台电脑
- + """ % { "css": SITE_CSS, + "js": SITE_JS, + "BG": BG, + "plats": plats, "badge": '
%s
' % 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 "选择你的设备", + "dlsub": ("当前版本 " + version) if version else "选择你的设备", }).replace("__LOGO__", _logo())