mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 21:50:44 +00:00
用户这次提了一整套升级,这是最后一批。
== 一、手表端顶部快捷栏 ==
用户原话:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
自定义,最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。
("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。)
* 一条常驻的栏,贴在屏幕顶部,切页面时不动
* **4 个格子**:3 个可自定义 + **1 个固定的「显示桌面」(改不了)**
* 7 个可选动作:传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 /
语音播报 / 投图到电脑
* 设置页里点一下换;也能把某一格留空
* 没连上电脑时按钮置灰(不是藏起来 —— "一直在但暂时不能用"
比"时有时无"更好理解)
实现上的几个要点:
* 快捷栏 addView 在 5 个面板**之后** —— FrameLayout 里后加的在上层,
加在前面会被面板(MATCH_PARENT)整个盖住
* 面板加了 topMargin 给栏让位,集中在 applyPanelTopMargin 里改
(每个面板各写一遍很容易漏一个,表现是"就某一页顶部被切掉")
* 第 4 格在代码里**写死** desktop,而且 QUICK_ACTIONS 里**没有**它 ——
这样它既不会出现在可选列表里,也不可能被改成别的
* 存储用 SharedPreferences。读取时**过滤不认识的 id + 补齐到 3 个**:
以后删了某个动作,老配置也不会让栏变空或下标越界
== 二、远程鼠标控制(手表上的触摸板)==
* 拖动 = 移动光标,单击 = 左键,双击 = 右键
* 下面还有专门的 左键 / 右键 / 滚轮上 / 滚轮下 按钮
* 电脑端用 SendInput 注入(见下)
== 三、弹文字窗口的位置和大小可配 ==
用户原话:"弹文字窗口 …… 位置和大小都能自定义"。
* 位置:右下(默认,保持老习惯)/ 右上 / 左上 / 左下 /
屏幕正中间 / 自定义坐标
* 字号:8~32(夹住,免得设成 200 一个通知占满屏幕)
* 宽度:可指定,0 = 按内容自适应
* 三处窗口定位统一到一个 _notice_geometry() —— 以前三处各写死一遍
右下角,改一次要改三个地方,漏一个用户就看得见
== 四、屏幕被观看时的提示条改成默认不显示 ==
用户原话:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。
* 默认**连窗口都不建**
* **但保留成开关**,设置里可以打开
关于这条:那条提示原来是**故意加**的(有人在看屏幕时,电脑前面的人
应该知道)。用户要求去掉,这是他自己的电脑、自己的教室,
属于正当的远程管理需求。所以能力保留、默认关闭 ——
这软件应该始终有一个"让本机用户看得见正在被控制"的选项可查。
== 五、中转后台能看到版本并远程点更新 ==
* 设备表新增「版本」列
* 每行一个「更新」按钮:点一下让那台电脑自己静默升级
* 离线设备的按钮**置灰并说明原因**(命令只会挂在队列里等它上线,
那时用户早忘了自己点过,突然重启很莫名其妙)
* 和手表那边走**同一条命令**(cmd=update, action=start),
不给"后台触发"单独写一套逻辑 —— 少一套就少一处不一致
== 六、手表端可开关「允许新设备连接」 ==
* 关掉后只有配对过的电脑能连,教室里防别人乱连
* 电脑端新增 _do_allow_new_pairing 这个 feature
* 只影响**配对新设备**,不踢现有会话
(否则老师一点"禁止",自己正连着的那台也被踢掉,成了"自杀开关")
== 修掉的几个自己造的坑 ==
1. **管理页 JS 裸换行** —— 最严重的一个。
ADMIN_JS 是 Python 三引号字符串,我在里面写 `\n` 拼多行文案,
结果是**真的换行**插进 JS,字符串字面量跨行 -> 语法非法 ->
**整个 script 块解析失败 -> 管理页所有按钮全死**(改码/删除/踢下线/全选)。
改成 String.fromCharCode(10),不依赖任何转义层。
2. **路由落进了 do_GET** —— 我拿 "没有这个管理接口" 当锚点,
但那个字符串在文件里有**两处**(do_GET 和 _route_post 各一个),
于是路由加到了 GET 块里,管理页的 POST 打不到它。
表现是"点了更新没反应"。
3. **isConnected() 根本不存在** —— 写快捷栏时凭直觉调了个方法名,
编译才发现。顺手包了一层并加上说明。
4. **两个不存在的方法名** —— onCreate 里写了
buildMousePanelIfNeeded / mousePanelHolder,编译报 cannot find symbol。
5. **未调用方法扫描的假阳性** —— GestureDetector 的
onSingleTapConfirmed / onDoubleTap / onScroll 是框架回调,
扫描把"名字只出现 1 次"当成死代码。改成**跳过 @Override 的方法**。
这种假阳性最坏的结果是有人为了让它变绿把回调删了,手势就失灵了。
6. **测试断言太严导致误报** —— "不能有写死的 240",
但 helper 里 `auto = max(240, ...)` 是自适应的兜底,故意的。
7. **屏幕共享提示那个测试断言的是旧行为** —— 没有简单删掉,
改成验两个方向:开关打开时提示条正常(位置/尺寸/出现/消失),
关闭时默认值必须是 False 且代码路径上不建窗口。
== 验证 ==
全量回归(19 关)全部 PASS,零失败
新增关卡:3a10 静默更新 / 3a11 鼠标控制 / 3a12 后台更新+弹窗 /
3a13 手表快捷栏
Java 编译通过(android-34, --release 8)
APK / 安装包 / 服务端包 全部重建,Win7 兼容校验通过
623 个 Python 文件语法通过
836 lines
33 KiB
Python
836 lines
33 KiB
Python
# -*- coding: utf-8 -*-
|
||
r'''中转站管理后台的页面渲染(独立模块)。
|
||
|
||
为什么拆出来:
|
||
原来这一大坨内联在 relay.py 里(几百行 f-string + CSS + JS),
|
||
改一处样式要在一堆转义的花括号里找,改不动也没法看。
|
||
拆成模块后:CSS 是普通字符串、HTML 用普通格式化,清爽多了。
|
||
|
||
设计目标(用户反馈"难看的要死"):
|
||
* 和官网共用一套设计语言:白底、浅灰面板、细边框、克制的蓝
|
||
* 信息分层次:
|
||
顶部栏(我是谁、什么状态)
|
||
-> 公网地址(最该被看到、要复制的东西,给最大权重)
|
||
-> 四个数字(一眼看全局)
|
||
-> 设备表(主内容)
|
||
-> 工具卡(体检、下载,次要,放最后)
|
||
* 表格:状态用圆点+文字,配对码等宽字体,行悬停高亮
|
||
* 空状态要友好,不是干巴巴一句"没有数据"
|
||
|
||
功能一个都不能少(对应用户能做的事):
|
||
单台删除 / 勾选批量删除 / 删除全部 / 复制公网地址 /
|
||
网络体检 / 下载最新版 / 自动刷新开关
|
||
'''
|
||
from __future__ import annotations
|
||
|
||
# ---------------- 设计令牌(和官网一致) ----------------
|
||
BG = "#ffffff"
|
||
SURFACE = "#fafbfc"
|
||
BORDER = "#e6e8eb"
|
||
BORDER_SOFT = "#f0f2f5"
|
||
TEXT = "#1a1d21"
|
||
MUTED = "#6b7280"
|
||
FAINT = "#9ca3af"
|
||
ACCENT = "#2563eb"
|
||
OK = "#16a34a"
|
||
WARN = "#d97706"
|
||
DANGER = "#dc2626"
|
||
|
||
TITLE = "ykxmz 中转站"
|
||
|
||
ADMIN_CSS = """
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#ffffff; --surface:#fafbfc; --border:#e6e8eb; --border-soft:#f0f2f5;
|
||
--fg:#1a1d21; --muted:#6b7280; --faint:#9ca3af;
|
||
--accent:#2563eb; --ok:#16a34a; --warn:#d97706; --danger:#dc2626;
|
||
}
|
||
html,body{max-width:100%%;overflow-x:hidden}
|
||
body{background:var(--bg);color:var(--fg);line-height:1.6;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',
|
||
'PingFang SC',sans-serif;-webkit-font-smoothing:antialiased}
|
||
a{color:inherit}
|
||
.wrap{width:100%%;max-width:1040px;margin:0 auto;padding:0 20px 64px}
|
||
|
||
/* ---------- 吸顶顶栏 ---------- */
|
||
.top{position:sticky;top:0;z-index:40;background:rgba(255,255,255,.88);
|
||
backdrop-filter:saturate(180%%) blur(12px);border-bottom:1px solid var(--border)}
|
||
/*
|
||
* ⚠ 别用固定 height。
|
||
* 原来写的是 .top-in{height:62px},但里面品牌是「标题 + 副标题」两行、
|
||
* 右边还有两行版本信息 —— 实际内容比 62px 高,
|
||
* flex 就把内容往上顶出去了:**标题被切掉、运行中徽章压在字上**
|
||
* (用户截图里就是这样)。
|
||
* 改成 padding 撑开,内容多高就是多高。
|
||
*/
|
||
.top-in{display:flex;align-items:center;gap:12px;
|
||
padding-top:13px;padding-bottom:13px;min-height:36px}
|
||
.logo{width:32px;height:32px;border-radius:9px;flex:none;
|
||
background:linear-gradient(140deg,#2563eb,#1d4ed8);
|
||
display:flex;align-items:center;justify-content:center;
|
||
color:#fff;font-weight:700;font-size:12px;letter-spacing:-.3px}
|
||
.brand{flex:none;min-width:0}
|
||
.brand b{display:block;font-size:15px;font-weight:650;letter-spacing:-.2px;
|
||
line-height:1.35;white-space:nowrap}
|
||
.brand span{display:block;color:var(--faint);font-size:11px;
|
||
line-height:1.4;white-space:nowrap}
|
||
.state{margin-left:8px;flex:none;display:inline-flex;align-items:center;gap:6px;
|
||
padding:3px 10px;border-radius:999px;background:#f0fdf4;
|
||
color:#15803d;font-size:11px;font-weight:600;white-space:nowrap}
|
||
.state i{width:6px;height:6px;border-radius:50%%;background:#22c55e;
|
||
display:block;flex:none}
|
||
.top-right{margin-left:auto;text-align:right;font-size:11px;
|
||
color:var(--faint);line-height:1.65;min-width:0}
|
||
.top-right b{color:var(--muted);font-weight:500}
|
||
.logout{color:var(--muted);text-decoration:none;
|
||
border-bottom:1px dashed var(--border);transition:.15s}
|
||
.logout:hover{color:var(--danger);border-bottom-color:var(--danger)}
|
||
|
||
/* 窄屏:状态徽章换行,别和标题挤 */
|
||
@media(max-width:640px){
|
||
.top-in{flex-wrap:wrap;gap:8px}
|
||
.state{margin-left:auto}
|
||
}
|
||
|
||
/* ---------- 公网地址(最重要,给最大权重)---------- */
|
||
.hero{padding:30px 0 26px}
|
||
.hero h2{font-size:13px;font-weight:600;color:var(--muted);
|
||
margin-bottom:10px;letter-spacing:.2px}
|
||
.urlbox{display:flex;align-items:stretch;gap:10px;flex-wrap:wrap}
|
||
.url{flex:1;min-width:240px;display:flex;align-items:center;
|
||
font:600 16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;
|
||
color:var(--accent);word-break:break-all;
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:11px;padding:14px 16px}
|
||
.url.empty{color:var(--faint);font-weight:400;font-size:14px}
|
||
.hint{color:var(--faint);font-size:12px;margin-top:9px}
|
||
|
||
/* ---------- 数字卡 ---------- */
|
||
.nums{display:grid;gap:10px;grid-template-columns:repeat(2,1fr);
|
||
margin-bottom:30px}
|
||
@media(min-width:640px){.nums{grid-template-columns:repeat(4,1fr)}}
|
||
.num{background:var(--surface);border:1px solid var(--border);
|
||
border-radius:12px;padding:15px 16px}
|
||
.num .v{font-size:25px;font-weight:700;letter-spacing:-.7px;
|
||
line-height:1.1;font-variant-numeric:tabular-nums}
|
||
.num .k{color:var(--muted);font-size:12px;margin-top:3px}
|
||
.num.live{background:#f0fdf4;border-color:#bbf7d0}
|
||
.num.live .v{color:#15803d}
|
||
.num.busy{background:#fffbeb;border-color:#fde68a}
|
||
.num.busy .v{color:#b45309}
|
||
|
||
/* ---------- 区块标题 ---------- */
|
||
.sec{display:flex;align-items:center;gap:12px;margin-bottom:12px}
|
||
.sec h3{font-size:15px;font-weight:650;letter-spacing:-.2px}
|
||
.sec .right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||
|
||
/* ---------- 表格 ---------- */
|
||
.card{background:#fff;border:1px solid var(--border);border-radius:13px;
|
||
overflow:hidden}
|
||
table{width:100%%;border-collapse:collapse;font-size:13px}
|
||
thead th{background:var(--surface);text-align:left;font-weight:600;
|
||
color:var(--muted);font-size:11.5px;letter-spacing:.3px;
|
||
padding:11px 14px;border-bottom:1px solid var(--border);
|
||
white-space:nowrap}
|
||
tbody td{padding:13px 14px;border-bottom:1px solid var(--border-soft);
|
||
vertical-align:middle}
|
||
tbody tr:last-child td{border-bottom:none}
|
||
tbody tr:hover td{background:#fbfcfd}
|
||
tbody tr.picked td{background:#eff6ff}
|
||
.dot{width:7px;height:7px;border-radius:50%%;display:inline-block;
|
||
margin-right:7px;flex:none}
|
||
.dot.on{background:#22c55e}
|
||
.dot.off{background:#d1d5db}
|
||
.devname{display:flex;align-items:center;font-weight:600}
|
||
.devid{color:var(--faint);font-size:10.5px;margin-top:2px;
|
||
font-family:ui-monospace,Consolas,monospace;
|
||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}
|
||
code{font:600 13px/1 ui-monospace,Consolas,monospace;
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:6px;padding:4px 8px;letter-spacing:.5px}
|
||
.pill{padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600}
|
||
.pill.on{background:#dcfce7;color:#15803d}
|
||
.pill.off{background:#f3f4f6;color:#6b7280}
|
||
.mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;
|
||
color:var(--muted)}
|
||
.ops{text-align:right}
|
||
.empty{padding:56px 20px;text-align:center}
|
||
.empty .big{font-size:15px;font-weight:600;margin-bottom:8px}
|
||
.empty p{color:var(--muted);font-size:13px;max-width:340px;margin:0 auto}
|
||
.empty .ico{font-size:30px;margin-bottom:14px;opacity:.35}
|
||
|
||
/* ---------- 按钮 ---------- */
|
||
button{font-family:inherit;font-size:13px;cursor:pointer;
|
||
border-radius:9px;padding:8px 15px;border:1px solid var(--border);
|
||
background:#fff;color:var(--text);transition:.14s;
|
||
font-weight:500}
|
||
button:hover:not(:disabled){background:var(--surface);
|
||
border-color:#d1d5db}
|
||
button:active:not(:disabled){transform:translateY(1px)}
|
||
button:disabled{opacity:.42;cursor:not-allowed}
|
||
button.primary{background:var(--accent);border-color:var(--accent);
|
||
color:#fff;font-weight:600}
|
||
button.primary:hover:not(:disabled){background:#1d4ed8;
|
||
border-color:#1d4ed8}
|
||
button.danger{color:#b91c1c;border-color:#fecaca;background:#fff}
|
||
button.danger:hover:not(:disabled){background:#fef2f2;
|
||
border-color:#fca5a5}
|
||
button.sm{padding:6px 12px;font-size:12px}
|
||
|
||
/* ---------- 工具卡 ---------- */
|
||
.tools{display:grid;gap:14px;margin-top:30px}
|
||
@media(min-width:720px){.tools{grid-template-columns:1fr 1fr}}
|
||
.tool{border:1px solid var(--border);border-radius:13px;padding:18px 18px}
|
||
.tool h4{font-size:13.5px;font-weight:650;margin-bottom:4px}
|
||
.tool .d{color:var(--muted);font-size:12.5px;margin-bottom:14px}
|
||
.tool .out{color:var(--muted);font-size:12.5px;line-height:1.85;
|
||
word-break:break-all}
|
||
.tool .out b{color:var(--fg);font-weight:600}
|
||
.tool .out .good{color:#15803d;font-weight:600}
|
||
.tool .out .bad{color:#b91c1c;font-weight:600}
|
||
.dlrow{display:flex;align-items:center;gap:10px;padding:7px 0;
|
||
border-bottom:1px solid var(--border-soft);font-size:12.5px}
|
||
.dlrow:last-child{border-bottom:none}
|
||
.dlrow .nm{flex:1;overflow:hidden;text-overflow:ellipsis;
|
||
white-space:nowrap;font-family:ui-monospace,Consolas,monospace;
|
||
font-size:11.5px}
|
||
.dlrow .sz{color:var(--faint);font-variant-numeric:tabular-nums}
|
||
|
||
/* ---------- 底部说明 ---------- */
|
||
.foot{margin-top:36px;padding-top:20px;border-top:1px solid var(--border);
|
||
color:var(--faint);font-size:11.5px;line-height:1.9}
|
||
.foot b{color:var(--muted);font-weight:600}
|
||
|
||
/* ---------- 提示条 ---------- */
|
||
#toast{position:fixed;left:50%%;bottom:30px;transform:translateX(-50%%)
|
||
translateY(20px);background:#1a1d21;color:#fff;padding:11px 20px;
|
||
border-radius:10px;font-size:13px;opacity:0;pointer-events:none;
|
||
transition:.22s;z-index:99;max-width:86vw;text-align:center}
|
||
#toast.show{opacity:1;transform:translateX(-50%%) translateY(0)}
|
||
#toast.ok{background:#15803d}
|
||
#toast.err{background:#b91c1c}
|
||
|
||
/* ---------- 窄屏:表格变卡片 ---------- */
|
||
@media(max-width:700px){
|
||
thead{display:none}
|
||
tbody tr{display:block;border-bottom:1px solid var(--border);
|
||
padding:12px 14px}
|
||
tbody td{display:flex;justify-content:space-between;gap:14px;
|
||
padding:5px 0;border:none}
|
||
tbody td::before{content:attr(data-l);color:var(--faint);font-size:11.5px;
|
||
flex:none}
|
||
td.pickcell::before,
|
||
td.ops::before{display:none}
|
||
td.pickcell{padding-bottom:8px}
|
||
td.ops{justify-content:flex-end;padding-top:10px;
|
||
border-top:1px solid var(--border-soft)}
|
||
td.empty{display:block;text-align:center}
|
||
td.empty::before{display:none}
|
||
.devid{max-width:200px}
|
||
}
|
||
""" % {}
|
||
|
||
|
||
def _esc(value) -> str:
|
||
"""HTML 转义。"""
|
||
text = "" if value is None else str(value)
|
||
return (text.replace("&", "&").replace("<", "<")
|
||
.replace(">", ">").replace('"', """))
|
||
|
||
|
||
def _uptime(seconds) -> str:
|
||
try:
|
||
total = int(seconds)
|
||
except (TypeError, ValueError):
|
||
return "—"
|
||
if total < 60:
|
||
return "%d 秒" % total
|
||
if total < 3600:
|
||
return "%d 分" % (total // 60)
|
||
if total < 86400:
|
||
return "%d 小时 %d 分" % (total // 3600, (total % 3600) // 60)
|
||
return "%d 天 %d 小时" % (total // 86400, (total % 86400) // 3600)
|
||
|
||
|
||
def _kb(value) -> str:
|
||
try:
|
||
n = float(value)
|
||
except (TypeError, ValueError):
|
||
return "0"
|
||
if n < 1024:
|
||
return "%d B" % n
|
||
if n < 1024 * 1024:
|
||
return "%.0f KB" % (n / 1024)
|
||
return "%.1f MB" % (n / 1024 / 1024)
|
||
|
||
|
||
def _device_rows(devices: list) -> str:
|
||
if not devices:
|
||
# 图标用内联 SVG 而不是 emoji ——
|
||
# emoji 依赖系统字体,无头浏览器/精简系统里会渲染成方框。
|
||
return ('<tr><td colspan="9" class="empty">'
|
||
'<svg width="34" height="34" viewBox="0 0 24 24" fill="none" '
|
||
'stroke="currentColor" stroke-width="1.5" '
|
||
'stroke-linecap="round" stroke-linejoin="round" '
|
||
'style="opacity:.28;margin-bottom:14px">'
|
||
'<rect x="2" y="4" width="20" height="13" rx="2"/>'
|
||
'<path d="M8 21h8M12 17v4"/></svg>'
|
||
'<div class="big">还没有电脑连上来</div>'
|
||
'<p>在教室那台电脑上双击 ykxmz.exe 设好 6 位配对码,'
|
||
'手表上输这 6 位数字就能连上。</p>'
|
||
'</td></tr>')
|
||
|
||
out = []
|
||
for device in devices:
|
||
online = device["online"]
|
||
name = _esc(device["name"])
|
||
did = _esc(device["deviceId"])
|
||
code = _esc(device["code"])
|
||
out.append(
|
||
'<tr>'
|
||
'<td class="pickcell">'
|
||
'<input type="checkbox" class="pick" value="%s" '
|
||
'aria-label="选择 %s"></td>'
|
||
'<td data-l="设备">'
|
||
'<div class="devname">'
|
||
'<span class="dot %s"></span>%s</div>'
|
||
'<div class="devid">%s</div></td>'
|
||
'<td data-l="配对码"><code>%s</code></td>'
|
||
# 版本列:用户要求"手表端连接那个电脑端也可以查看电脑端
|
||
# 目前所处的版本",后台这边同理 —— 一眼看出哪台该升级。
|
||
'<td data-l="版本"><span class="mono">%s</span></td>'
|
||
'<td data-l="状态"><span class="pill %s">%s</span></td>'
|
||
'<td data-l="空闲"><span class="mono">%ss</span></td>'
|
||
'<td data-l="待发 表/机"><span class="mono">%d / %d</span></td>'
|
||
'<td data-l="流量 入/出"><span class="mono">%s / %s</span></td>'
|
||
'<td class="ops">'
|
||
# ⚠ 这个按钮用 data-recode 而不是 data-device ——
|
||
# 现有"单台删除"是按 button.danger[data-device] 绑的,
|
||
# 这里要是也带 data-device 就会被当成删除按钮。
|
||
'<button class="ghost sm" data-recode="%s" '
|
||
'data-name="%s" data-code="%s">改码</button>'
|
||
# 「更新」只对在线设备有意义(要给它发命令)。
|
||
# 离线的话按钮置灰并说明原因,而不是点了没反应。
|
||
'<button class="ghost sm" data-update="%s" '
|
||
'data-name="%s" data-code="%s"%s>更新</button>'
|
||
'<button class="danger sm" '
|
||
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
|
||
'</tr>' % (
|
||
did, name,
|
||
"on" if online else "off", name, did,
|
||
code,
|
||
_esc(device.get("version") or "—"),
|
||
"on" if online else "off", "在线" if online else "离线",
|
||
device["idleSeconds"],
|
||
device["queuedToClient"], device["queuedToAgent"],
|
||
_kb(device["bytesIn"]), _kb(device["bytesOut"]),
|
||
did, name, code,
|
||
did, name, code,
|
||
"" if online else ' disabled title="设备离线,连不上"',
|
||
did, name, code)
|
||
)
|
||
return "".join(out)
|
||
|
||
|
||
ADMIN_JS = """
|
||
const toast = (msg, kind) => {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.className = kind ? ('show ' + kind) : 'show';
|
||
clearTimeout(el._t);
|
||
el._t = setTimeout(() => { el.className = ''; }, 2800);
|
||
};
|
||
|
||
const post = async (path, payload) => {
|
||
const r = await fetch(path, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(payload || {})
|
||
});
|
||
return r.json();
|
||
};
|
||
|
||
// ---------------- 允许/禁止新设备接入 ----------------
|
||
// 关掉之后,只有**已经登记过**的电脑能继续上线,
|
||
// 没见过的新设备一律拒绝。
|
||
const allowNew = document.getElementById('allowNew');
|
||
if (allowNew) allowNew.onchange = async () => {
|
||
const want = allowNew.checked;
|
||
allowNew.disabled = true;
|
||
try {
|
||
const j = await post('/v1/admin/allow_new', {allow: want});
|
||
if (j.ok) {
|
||
toast(j.allowNew ? '已允许新设备接入' : '已禁止新设备接入', 'ok');
|
||
} else {
|
||
toast('设置失败:' + (j.error || '未知错误'), 'err');
|
||
allowNew.checked = !want;
|
||
}
|
||
} catch (e) {
|
||
toast('请求出错:' + e, 'err');
|
||
allowNew.checked = !want;
|
||
}
|
||
allowNew.disabled = false;
|
||
};
|
||
|
||
// ---------------- 改配对码 ----------------
|
||
document.querySelectorAll('button[data-recode]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const name = b.dataset.name, old = b.dataset.code;
|
||
// ⚠ 下面 prompt 里的换行,必须写成「反斜杠 + n」两个字符。
|
||
// 本文件是普通三引号字符串:只写反斜杠 + n,Python 会把它
|
||
// 变成**真实换行**,拼进 JS 就是字符串里的裸换行 ->
|
||
// 整段 <script> 语法错误 -> 页面上所有按钮(改码/删除/
|
||
// 批量删除/全选)全部失效。
|
||
//
|
||
// 这个坑踩过两次:
|
||
// 1) 第一次是 prompt 里的换行本身;
|
||
// 2) 第二次就是**这段注释自己** —— 为了举例说明「不能那样写」,
|
||
// 注释里原样敲了那个转义,结果注释被断成两行,把整段
|
||
// JS 又弄坏了一次。所以这里一个转义序列都不写。
|
||
const typed = prompt('把「' + name + '」的配对码 ' + old
|
||
+ ' 改成:\\n\\n(必须是 6 位数字)', '');
|
||
if (typed === null) return;
|
||
const code = (typed || '').trim();
|
||
if (!/^[0-9]{6}$/.test(code)) {
|
||
toast('配对码必须是 6 位数字', 'err');
|
||
return;
|
||
}
|
||
b.disabled = true;
|
||
try {
|
||
const j = await post('/v1/admin/set_code',
|
||
{deviceId: b.dataset.recode, code: code});
|
||
if (j.ok) {
|
||
toast(j.unchanged ? '配对码没有变化'
|
||
: ('配对码已改成 ' + j.code
|
||
+ (j.kicked ? ',踢掉 ' + j.kicked + ' 个会话' : '')),
|
||
'ok');
|
||
setTimeout(() => location.reload(), 900);
|
||
} else {
|
||
toast('改码失败:' + (j.error || '未知错误'), 'err');
|
||
b.disabled = false;
|
||
}
|
||
} catch (e) {
|
||
toast('请求出错:' + e, 'err');
|
||
b.disabled = false;
|
||
}
|
||
};
|
||
});
|
||
|
||
// ---------------- 单台删除 ----------------
|
||
// ---- 「更新」按钮:让那台电脑自己静默升级 ----
|
||
//
|
||
// 和手表那边走的是同一条命令,只是入口不同。
|
||
// 离线设备的按钮在 HTML 里就带 disabled 了,所以这里不用再判。
|
||
document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const name = btn.dataset.name || '这台电脑';
|
||
// ⚠ 这里**不能用反斜杠 n** 拼多行文案。
|
||
// ADMIN_JS 是 Python 三引号字符串,源码里写反斜杠 n 会变成
|
||
// **真的换行**插进 JS,于是字符串字面量跨行 —— JS 语法非法,
|
||
// 整个 script 块解析失败,管理页所有按钮全死。
|
||
// (回归里的「管理页 JS 合法性」就是专门抓这个的。)
|
||
//
|
||
// 用 String.fromCharCode(10) 完全不依赖转义层,
|
||
// 以后谁改这段都不会再踩。
|
||
const NL = String.fromCharCode(10);
|
||
const msg = ['让「' + name + '」现在更新到最新版?', '',
|
||
'它会自己下载、静默安装并重启,全程不弹窗口,',
|
||
'中转地址、配对码和各项设置都不变。'].join(NL);
|
||
if (!confirm(msg)) {
|
||
return;
|
||
}
|
||
btn.disabled = true;
|
||
const old = btn.textContent;
|
||
btn.textContent = '下发中…';
|
||
try {
|
||
const r = await post('/v1/admin/update_device',
|
||
{ deviceId: btn.dataset.update });
|
||
toast((r && r.message) || '已下发更新命令', 'ok');
|
||
// 更新要跑一阵子,不立刻刷新列表(刷新了也看不出变化)
|
||
} catch (e) {
|
||
toast('下发失败:' + (e && e.message ? e.message : e), 'err');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = old;
|
||
}
|
||
};
|
||
});
|
||
|
||
document.querySelectorAll('button.danger[data-device]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const name = b.dataset.name, code = b.dataset.code;
|
||
if (!confirm('删除设备「' + name + '」(配对码 ' + code + ')?\\n\\n'
|
||
+ '这会:\\n 1. 断开它现在的手表连接\\n'
|
||
+ ' 2. 从服务器删掉这台设备\\n'
|
||
+ ' 3. 清掉它的配对码绑定')) return;
|
||
try {
|
||
const j = await post('/v1/admin/delete', {deviceId: b.dataset.device});
|
||
if (j.ok) {
|
||
toast('已删除「' + (j.name || name) + '」,清掉配对码 '
|
||
+ (j.codesCleared || []).length + ' 条', 'ok');
|
||
setTimeout(() => location.reload(), 700);
|
||
} else {
|
||
toast('删除失败:' + (j.error || '未知错误'), 'err');
|
||
}
|
||
} catch (e) {
|
||
toast('请求出错:' + e, 'err');
|
||
}
|
||
};
|
||
});
|
||
|
||
// ---------------- 批量选择 ----------------
|
||
const picks = () => Array.from(document.querySelectorAll('input.pick'));
|
||
const picked = () => picks().filter(c => c.checked).map(c => c.value);
|
||
|
||
function refreshPickState() {
|
||
const chosen = picked().length;
|
||
const btn = document.getElementById('delPicked');
|
||
if (btn) {
|
||
btn.disabled = chosen === 0;
|
||
btn.textContent = chosen ? ('删除选中(' + chosen + ')') : '删除选中';
|
||
}
|
||
picks().forEach(c => {
|
||
const tr = c.closest('tr');
|
||
if (tr) tr.classList.toggle('picked', c.checked);
|
||
});
|
||
const all = document.getElementById('pickAll');
|
||
if (all) {
|
||
const total = picks().length;
|
||
all.checked = total > 0 && chosen === total;
|
||
all.indeterminate = chosen > 0 && chosen < total;
|
||
}
|
||
}
|
||
|
||
picks().forEach(c => c.addEventListener('change', refreshPickState));
|
||
const pickAll = document.getElementById('pickAll');
|
||
if (pickAll) pickAll.addEventListener('change', () => {
|
||
picks().forEach(c => { c.checked = pickAll.checked; });
|
||
refreshPickState();
|
||
});
|
||
refreshPickState();
|
||
|
||
// ---------------- 批量删除 ----------------
|
||
const delPicked = document.getElementById('delPicked');
|
||
if (delPicked) delPicked.onclick = async () => {
|
||
const ids = picked();
|
||
if (!ids.length) return;
|
||
if (!confirm('删除选中的 ' + ids.length + ' 台设备?\\n\\n'
|
||
+ '会断开它们现在的手表连接,并清掉它们占的配对码。')) return;
|
||
delPicked.disabled = true;
|
||
try {
|
||
const j = await post('/v1/admin/delete_many', {deviceIds: ids});
|
||
if (j.ok) {
|
||
toast('已删除 ' + j.deleted + ' 台设备'
|
||
+ (j.failed ? (',' + j.failed + ' 台失败') : ''), 'ok');
|
||
setTimeout(() => location.reload(), 700);
|
||
} else {
|
||
toast('删除失败:' + (j.error || '未知错误'), 'err');
|
||
delPicked.disabled = false;
|
||
}
|
||
} catch (e) {
|
||
toast('请求出错:' + e, 'err');
|
||
delPicked.disabled = false;
|
||
}
|
||
};
|
||
|
||
// ---------------- 删除全部 ----------------
|
||
const delAll = document.getElementById('delAll');
|
||
if (delAll) delAll.onclick = async () => {
|
||
if (!confirm('删除全部设备(连同所有配对码)?\\n\\n'
|
||
+ '清场用,删完所有电脑都要重新设码。')) return;
|
||
try {
|
||
const j = await post('/v1/admin/delete_all');
|
||
toast('已删除 ' + (j.deleted || 0) + ' 台设备', 'ok');
|
||
setTimeout(() => location.reload(), 700);
|
||
} catch (e) {
|
||
toast('请求出错:' + e, 'err');
|
||
}
|
||
};
|
||
|
||
// ---------------- 复制公网地址 ----------------
|
||
const copyUrl = document.getElementById('copyUrl');
|
||
if (copyUrl) copyUrl.onclick = async () => {
|
||
const text = document.getElementById('urlBox').textContent.trim();
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
toast('地址已复制', 'ok');
|
||
} catch (e) {
|
||
// 非安全上下文(http 且不是 localhost)没有 clipboard API,
|
||
// 退回到老办法:选中 + execCommand
|
||
const range = document.createRange();
|
||
range.selectNodeContents(document.getElementById('urlBox'));
|
||
const sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
try { document.execCommand('copy'); toast('地址已复制', 'ok'); }
|
||
catch (e2) { toast('复制失败,请手动选中', 'err'); }
|
||
}
|
||
};
|
||
|
||
// ---------------- 网络体检 ----------------
|
||
const netBtn = document.getElementById('netBtn');
|
||
if (netBtn) netBtn.onclick = async () => {
|
||
const body = document.getElementById('netBody');
|
||
body.textContent = '正在测…';
|
||
try {
|
||
const r = await fetch('/v1/admin/netcheck');
|
||
const j = await r.json();
|
||
if (!j.ok) {
|
||
body.innerHTML = '<span class="bad">读不到:'
|
||
+ (j.error || '未知') + '</span>';
|
||
return;
|
||
}
|
||
const rows = [];
|
||
if (j.tunnelMetrics) {
|
||
const conns = j.tunnelConnections || 0;
|
||
const cls = conns >= 2 ? 'good' : 'bad';
|
||
rows.push('隧道连接数:<b class="' + cls + '">' + conns + '</b>'
|
||
+ (conns === 0 ? '(断了,看门狗会自动重启)' : ''));
|
||
rows.push('隧道累计请求:<b>' + (j.tunnelRequests || 0) + '</b>');
|
||
} else {
|
||
rows.push('<span class="bad">读不到隧道指标'
|
||
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
|
||
}
|
||
rows.push('登记设备:<b>' + j.devices + '</b>(在线 <b>' + j.online
|
||
+ '</b>)');
|
||
rows.push('手表会话:<b>' + j.sessions + '</b>');
|
||
rows.push('中转内存:<b>' + Math.round(j.memoryKB / 1024) + ' MB</b>');
|
||
rows.push('已运行:<b>' + Math.round(j.uptimeSeconds / 60) + ' 分钟</b>');
|
||
if (j.publicUrl) {
|
||
rows.push('公网地址:<b>' + j.publicUrl + '</b>');
|
||
}
|
||
body.innerHTML = rows.map(t => '<div>' + t + '</div>').join('');
|
||
// 顺便测浏览器到中转的往返
|
||
const t0 = performance.now();
|
||
try {
|
||
await fetch('/v1/health?t=' + Date.now(), {cache: 'no-store'});
|
||
const ms = Math.round(performance.now() - t0);
|
||
body.innerHTML += '<div>浏览器到中转往返:<b class="'
|
||
+ (ms < 300 ? 'good' : 'bad') + '">' + ms + ' ms</b>'
|
||
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
|
||
+ '</div>';
|
||
} catch (e) {
|
||
body.innerHTML += '<div class="bad">浏览器到中转:失败</div>';
|
||
}
|
||
} catch (e) {
|
||
body.textContent = '请求出错:' + e;
|
||
}
|
||
};
|
||
|
||
// ---------------- 自动刷新 ----------------
|
||
const autoRef = document.getElementById('autoRef');
|
||
let autoTimer = null;
|
||
function setupAuto() {
|
||
clearInterval(autoTimer);
|
||
if (autoRef && autoRef.checked) {
|
||
autoTimer = setInterval(() => {
|
||
// 有人正在勾选/删除时别刷新,免得打断操作
|
||
if (picked().length === 0 && !document.hidden) location.reload();
|
||
}, 10000);
|
||
}
|
||
}
|
||
if (autoRef) {
|
||
autoRef.addEventListener('change', () => {
|
||
try {
|
||
localStorage.setItem('lr_autoref', autoRef.checked ? '1' : '0');
|
||
} catch (e) {}
|
||
setupAuto();
|
||
});
|
||
try {
|
||
if (localStorage.getItem('lr_autoref') === '0') autoRef.checked = false;
|
||
} catch (e) {}
|
||
setupAuto();
|
||
}
|
||
"""
|
||
|
||
|
||
def _rescue_rows(items: list) -> str:
|
||
"""抢救报告列表的 HTML。
|
||
|
||
照 dl_rows 的风格:在服务端直接拼好塞进模板,不用 JS 去拉 ——
|
||
这台机器只有 460MB 内存,多个接口就多份开销,而且后台页
|
||
本来就只是一次性的展示。
|
||
|
||
没有报告时给一句正面的话("一直没出过问题"),
|
||
而不是干巴巴的"暂无数据"—— 没报告是好事。
|
||
"""
|
||
if not items:
|
||
return ('<div class="out">还没有报告 —— 说明网站一直没出过'
|
||
'「自动救不回来」的问题。</div>')
|
||
import time as _t
|
||
import urllib.parse as _up
|
||
rows = []
|
||
for it in items:
|
||
when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"]))
|
||
size = it["size"] / 1024
|
||
size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"])
|
||
summary = _esc(it.get("summary") or "(没有结论行)")
|
||
href = "/rescue/" + _up.quote(it["name"])
|
||
rows.append(
|
||
'<div class="dlrow"><span class="nm">'
|
||
'<a href="%s" style="color:#0a84ff">%s</a>'
|
||
'<span style="color:#6b7280;font-size:12px;margin-left:8px">'
|
||
'%s</span></span>'
|
||
'<span class="sz" style="max-width:46%%;text-align:right">'
|
||
'%s</span></div>'
|
||
% (href, when, size_s, summary))
|
||
return "".join(rows)
|
||
|
||
|
||
def render_admin(snap: dict, public: str, admin_path: str, codes: int,
|
||
downloads_fn, rescue_fn=None) -> str:
|
||
"""渲染管理后台。
|
||
|
||
snap RELAY.snapshot() 的结果
|
||
public 公网地址(空串表示隧道没起来)
|
||
admin_path 管理页路径(用于退出登录链接)
|
||
codes 当前占用的配对码数
|
||
downloads_fn 取下载列表的函数(在 relay.py 里)
|
||
"""
|
||
devices = snap.get("devices", [])
|
||
online_count = sum(1 for d in devices if d.get("online"))
|
||
url_text = public or ""
|
||
url_class = "url empty" if not url_text else "url"
|
||
url_display = _esc(url_text) if url_text else "还没有地址 —— 隧道可能没起来"
|
||
sessions = snap.get("sessions", 0)
|
||
# 开关状态:允许新设备接入时勾上
|
||
allow_new = bool(snap.get("allowNew", True))
|
||
|
||
# 下载列表
|
||
try:
|
||
files = downloads_fn()
|
||
except Exception: # noqa: BLE001
|
||
files = []
|
||
if files:
|
||
dl_rows = "".join(
|
||
'<div class="dlrow"><span class="nm">%s</span>'
|
||
'<span class="sz">%s</span></div>'
|
||
% (_esc(f["name"]), _kb(f["size"]))
|
||
for f in files[:8])
|
||
else:
|
||
dl_rows = ('<div class="out">还没有上传任何安装包。'
|
||
'把文件放到中转的 downloads 目录即可。</div>')
|
||
|
||
# 抢救报告列表(和下载区同样在页面里直接渲染,不用 JS 拉)
|
||
try:
|
||
rescue_items = rescue_fn() if rescue_fn else []
|
||
except Exception: # noqa: BLE001
|
||
rescue_items = []
|
||
rescue_rows = _rescue_rows(rescue_items)
|
||
|
||
return """<!doctype html>
|
||
<html lang="zh-CN"><head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<link rel="icon" href="/favicon.ico">
|
||
<title>%(title)s</title>
|
||
<style>%(css)s</style>
|
||
</head><body>
|
||
|
||
<div class="top"><div class="wrap top-in">
|
||
<div class="logo">yx</div>
|
||
<div class="brand">
|
||
<b>ykxmz 中转站</b>
|
||
<span>跨网络中转 · 管理后台</span>
|
||
</div>
|
||
<span class="state"><i></i>运行中</span>
|
||
<div class="top-right">
|
||
<div>版本 <b>%(ver)s</b> · 已运行 <b>%(up)s</b> · 内存 <b>%(mem)s MB</b></div>
|
||
<div>累计请求 <b>%(req)s</b> · <a class="logout" href="%(admin)s/logout">退出登录</a></div>
|
||
</div>
|
||
</div></div>
|
||
|
||
<div class="wrap">
|
||
|
||
<div class="hero">
|
||
<h2>公网地址 —— 手表「中转设置」里填这个</h2>
|
||
<div class="urlbox">
|
||
<div class="%(urlclass)s" id="urlBox">%(url)s</div>
|
||
<button class="primary" id="copyUrl"%(dis)s>复制</button>
|
||
</div>
|
||
<div class="hint">手表和电脑不在同一个网络时,两边都通过这个地址中转。</div>
|
||
</div>
|
||
|
||
<div class="nums">
|
||
<div class="num"><div class="v">%(ndev)s</div><div class="k">登记设备</div></div>
|
||
<div class="num %(oncls)s"><div class="v">%(non)s</div><div class="k">在线</div></div>
|
||
<div class="num %(sescls)s"><div class="v">%(nses)s</div><div class="k">手表会话</div></div>
|
||
<div class="num"><div class="v">%(ncodes)s</div><div class="k">配对码占用</div></div>
|
||
</div>
|
||
|
||
<div class="sec">
|
||
<h3>设备</h3>
|
||
<div class="right">
|
||
<label class="hint" id="allowWrap"
|
||
style="cursor:pointer;display:flex;align-items:center;gap:7px;margin:0 18px 0 0">
|
||
<input type="checkbox" id="allowNew"%(allownew)s>
|
||
<span>允许新设备接入</span>
|
||
</label>
|
||
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
|
||
<input type="checkbox" id="autoRef" checked> 自动刷新
|
||
</label>
|
||
<button class="danger sm" id="delPicked" disabled>删除选中</button>
|
||
<button class="danger sm" id="delAll">删除全部</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:38px"><input type="checkbox" id="pickAll" title="全选"></th>
|
||
<th>设备</th><th>配对码</th><th>版本</th><th>状态</th>
|
||
<th>空闲</th><th>待发 表/机</th><th>流量 入/出</th><th></th>
|
||
</tr></thead>
|
||
<tbody>
|
||
%(rows)s
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="tools">
|
||
<div class="tool" id="netPanel">
|
||
<h4>网络体检</h4>
|
||
<div class="d">看看这台机器是走局域网还是走隧道。</div>
|
||
<div class="out" id="netBody">还没测过。</div>
|
||
<div style="margin-top:14px"><button class="sm" id="netBtn">测一下</button></div>
|
||
</div>
|
||
<div class="tool">
|
||
<h4>下载区</h4>
|
||
<div class="d">官网下载按钮指向这里的文件。</div>
|
||
<div id="dlBody">%(dl)s</div>
|
||
</div>
|
||
<div class="tool">
|
||
<h4>抢救报告</h4>
|
||
<div class="d">网站挂了、自动预案也没救回来时,机器会把现场
|
||
打包成一份 txt 放在这里。点开看看是哪一段断了。</div>
|
||
<div id="rescueBody">%(rescue)s</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="foot">
|
||
<b>隐私说明</b> —— 数据来自 /v1/health,只显示配对码和流量数字,
|
||
看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
<script>%(js)s</script>
|
||
</body></html>""" % {
|
||
"title": TITLE, "css": ADMIN_CSS, "js": ADMIN_JS,
|
||
"ver": _esc(snap.get("version", "?")),
|
||
"up": _uptime(snap.get("uptimeSeconds", 0)),
|
||
"mem": int(snap.get("memoryKB", 0)) // 1024,
|
||
"req": snap.get("totalRequests", 0),
|
||
"admin": _esc(admin_path),
|
||
"urlclass": url_class, "url": url_display,
|
||
"dis": "" if url_text else " disabled",
|
||
"ndev": len(devices), "non": online_count,
|
||
"nses": sessions, "ncodes": codes,
|
||
"oncls": "live" if online_count else "",
|
||
"sescls": "busy" if sessions else "",
|
||
"rows": _device_rows(devices),
|
||
"allownew": " checked" if allow_new else "",
|
||
"dl": dl_rows,
|
||
"rescue": rescue_rows,
|
||
}
|