# -*- 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": '' '', "play": '' '', "image": '' '', "file": '' '', "shield": '', "power": '', "watch": '' '', "wifi": '' '', "lock": '' '', "bolt": '', "monitor": '' '', "phone": '' '', } def _icon(name: str, size: int = 24, stroke: float = 1.6) -> str: body = _ICONS.get(name, "") return ( '' % (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。 为什么不用 : ICO 放在 里各浏览器支持不一致,实测截图里是破图。 内联 SVG 永远能显示,还不用多一个请求。 """ return ( '' '' '' '' '' ).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 = ( '下载 %s' '
%s · %s
' % (quote(item["name"]), tag, item["name"], size) ) else: action = '
暂未上传
' cards.append( '
' '
%s
' '
%s' '%s
' '
%s
' '%s' '
%s
' '
' % (_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( '
%s
' '
%s
' '
%s
' % (delay, _icon(icon), title, desc) ) return "".join(out) def _step_cards() -> str: out = [] for index, (title, desc) in enumerate(STEPS, 1): out.append( '
%d
' '
%s
' '
%s
' % (index, title, desc) ) 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: out.append( '
%s' '
%s
' % (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( '%s%s' % (_icon(name, 15, 1.7), label) for name, label in (("monitor", "Windows 电脑"), ("watch", "安卓手表"), ("shield", "跨网络中转")) ) return (""" ykxmz · 用手表管教室那台电脑

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

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

%(plats)s
%(badge)s

能直连就一定直连

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

%(stats)s

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

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

%(feats)s

三步装好

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

%(steps)s
%(showcase)s

试过的老师怎么说

教学场景下的试用反馈

%(quotes)s

下载

%(dlsub)s

%(dls)s

常见问题

%(faq)s

装一次,以后就不用管了

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

下载安装
""" % { "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 "选择你的设备", }).replace("__LOGO__", _logo())