# -*- 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": '', "play": '', "image": '' '', "file": '' '', "shield": '', "power": '', "watch": '' '', "wifi": '' '', "lock": '' '', } def _icon(name: str, size: int = 22) -> str: body = _ICONS.get(name, "") return ( '%s' % (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。 为什么不用 : ICO 放在 里各浏览器支持不一致,实测截图里是破图。 内联 SVG 永远能显示,还不用多一个请求。 """ # 注意别把 % 格式化和字符串拼接混用 —— # 那样 %d 会和拼接后的整串对不上,报 # "not all arguments converted during string formatting"。 # 这里全部用 .format() 拼,清楚也不会踩坑。 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"), ("手表端", "安卓手表", "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 = ( '下载 %s' '
%s · %s
' % (quote(item["name"]), tag, item["name"], size) ) else: action = '
暂未上传
' cards.append( '
' '
%s' '%s
' '
%s
' '%s' '
%s
' '
' % (title, who, note, action, req) ) return "".join(cards) def _feature_cards() -> str: out = [] for icon, title, desc in FEATURES: out.append( '
%s
' '
%s
' '
%s
' % (_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 _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} 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 (""" ykxmz · 教室电脑手表遥控

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

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

%(badge)s

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

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

%(feats)s

三步装好

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

%(steps)s
%(showcase)s

下载

%(_dlsub)s

%(dls)s

常见问题

%(faq)s
""" % { "css": SITE_CSS, "badge": '
%s
' % 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())