fix(display,entry): 修大屏团体名次显示「-」与分项成绩第 3 栏缺第 1 名;发布改弹窗选口径;会话设备名与重命名;录分搜索框;版本 dev_0.5.15

本批七项(三处是现场报的 bug,四处是新功能):

① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」
   根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成
   「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底
   (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。
   改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。
   ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次,
   若通道名次优先,个人项目会在那种发布之后显示旧名次。
   ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。
   ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。

② 分项成绩页第 3 栏看不到第 1 名
   根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏,
   切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉
   (手录项目从第 1 名往下录 → 第 1 名建号最早)。
   改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行);
   组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。
   ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行);
   将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。

③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起
   多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度
   (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。
   取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。

④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一
   默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次;
   Alt+Enter 走同一个入口;取消 = 什么都不发生。
   审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。

⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目
   - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报,
     服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。
     ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。
   - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的
     display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签;
     显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示,
     不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。
   - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目
     (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。
     现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。

⑥ 局域网地址条目都能出二维码
   以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示,
   再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG
   (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。

⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲)
   复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、
   全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」;
   × / Esc / 清除三种清空方式;搜不到给明确空状态。
   ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、
      预览算分一律不受影响,界面上也写明了这一点。
   ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩
      静默写到看不见的人身上。

测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok,
EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状
「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium)
实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。

无数据库迁移;已有成绩、名次、积分与榜单不变。
文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法;
《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步;
devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。
This commit is contained in:
AskaEth committed 2026-09-17 18:19:38 +08:00
1 parent ee19b92182
commit 56ed90ff8b
53 files changed
+5675 -221

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.5.14 dev_0.5.15
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -1 +1 @@
0.5.14 0.5.15
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scoreboard-console", "name": "scoreboard-console",
"productName": "Scoreboard Console", "productName": "Scoreboard Console",
"version": "0.5.14", "version": "0.5.15",
"private": true, "private": true,
"author": "AskaEth", "author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+4 -3
View File
@@ -63,11 +63,12 @@
- [ ] 只有一块屏时是窗口化模式,按 **F11** 可切全屏(用于单屏测试) - [ ] 只有一块屏时是窗口化模式,按 **F11** 可切全屏(用于单屏测试)
- [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」;窗口化窗口(单屏)里显示这一行属正常,按 F11 全屏后它与自绘标题栏一起消失(版本号角标已移除,任何画面都不该出现) - [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」;窗口化窗口(单屏)里显示这一行属正常,按 F11 全屏后它与自绘标题栏一起消失(版本号角标已移除,任何画面都不该出现)
- [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常) - [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常)
- [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常) - [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」,并带一个**可读的设备标签**;两台同型号设备分不清就点「**改名**」起一个现场看得懂的名字),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常)
- [ ] **局域网大屏能在别的设备打开**:用另一台设备(平板 / 手机)浏览器打开 `http://<主机IP>:19540/display` 能出画面,或直接扫「大屏控制 → 局域网大屏」卡片第一个地址的**二维码**;打不开时先看卡片列出的地址与提示,再按常见问题「大屏控制页显示未检测到局域网地址怎么办?」逐条排查(若卡片地址是 `169.254.x.x`,说明本网段没有 DHCP,同网段的设备照样能连) - [ ] **局域网大屏能在别的设备打开**:用另一台设备(平板 / 手机)浏览器打开 `http://<主机IP>:19540/display` 能出画面,或直接扫「大屏控制 → 局域网大屏」卡片第一个地址的**二维码**;打不开时先看卡片列出的地址与提示,再按常见问题「大屏控制页显示未检测到局域网地址怎么办?」逐条排查(若卡片地址是 `169.254.x.x`,说明本网段没有 DHCP,同网段的设备照样能连)
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」 - [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
- [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏) - [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏)
- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布 → 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布) - [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布(发布窗口里按当天口径选「只更新名次」或「按决赛成绩发布」,选完才确认)→ 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布)
- [ ] **分项成绩页看得全**:切到大屏「分项成绩」页,**每一栏(含第 3 栏)都从第 1 名开始**;**团体项目的名次列不应是 `-`**(升级后不用重发、不用重算,刷新即可)。⚠️ 例外:只在「团体名次」卡片里按单位录了名次、成绩行上没填的项目仍无大屏名次(见常见问题 Q105 / Q106)
- [ ] **报名项批量操作演练**:在「成绩录入」勾选几行试一次**批量退赛 → 批量恢复**(确认框会逐条列出被跳过的 dq / dns 行及原因);「批量移出报名」是**物理删除、不可恢复**,只演示确认框、不要在真实报名上执行(详见使用手册 4.8) - [ ] **报名项批量操作演练**:在「成绩录入」勾选几行试一次**批量退赛 → 批量恢复**(确认框会逐条列出被跳过的 dq / dns 行及原因);「批量移出报名」是**物理删除、不可恢复**,只演示确认框、不要在真实报名上执行(详见使用手册 4.8)
- [ ] **报名名单导出演练**:「导出 → 结果导出 → 报名名单」按小项 / 组别 / 单位 / 批次筛一次并导出,核对 13 列与状态中文(正常参赛 / 已退赛 / 取消资格 / 未出场)与录入页一致 - [ ] **报名名单导出演练**:「导出 → 结果导出 → 报名名单」按小项 / 组别 / 单位 / 批次筛一次并导出,核对 13 列与状态中文(正常参赛 / 已退赛 / 取消资格 / 未出场)与录入页一致
@@ -82,7 +83,7 @@
## 7. 现场流程演练(建议至少一次) ## 7. 现场流程演练(建议至少一次)
- [ ] 录入员登录 → 录入一条成绩 → 存草稿 → 发布 → 大屏刷新 - [ ] 录入员登录 → 录入一条成绩 → 存草稿 → 点「**发布**」(弹出的窗口里**默认是「只更新名次(不计分、不发奖牌)」**;今天这一项要计分发牌就选「**按决赛成绩发布**」再确认)→ 大屏刷新
- [ ] 撤销发布 → 修改 → 重新发布(历史记录里能看到链路) - [ ] 撤销发布 → 修改 → 重新发布(历史记录里能看到链路)
- [ ] 导出核对:「结果导出」里「全部」与「全部(单页)」都能正常下载(后者应是一个工作簿、只有一张表);「积分榜」页三个榜单的卡片头各有导出——单位总分榜「导出总明细」得到 `单位积分总明细.xlsx`、单位奖牌榜「导出奖牌明细」得到 `单位奖牌明细.xlsx`、个人积分榜「导出个人明细」得到 `个人积分总明细.xlsx`;展开某单位后还能点「导出该单位明细」得到 `单位积分明细.xlsx` - [ ] 导出核对:「结果导出」里「全部」与「全部(单页)」都能正常下载(后者应是一个工作簿、只有一张表);「积分榜」页三个榜单的卡片头各有导出——单位总分榜「导出总明细」得到 `单位积分总明细.xlsx`、单位奖牌榜「导出奖牌明细」得到 `单位奖牌明细.xlsx`、个人积分榜「导出个人明细」得到 `个人积分总明细.xlsx`;展开某单位后还能点「导出该单位明细」得到 `单位积分明细.xlsx`
- [ ] 断网 → 大屏仍显示缓存数据 → 恢复后自动补增量 - [ ] 断网 → 大屏仍显示缓存数据 → 恢复后自动补增量
+61 -6
View File
@@ -112,6 +112,8 @@
表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。**纯团体报名这一格旁边还有一个「队员」小按钮**(点开登记 / 修改这支队的队员,见 4.13);普通报名没有这个按钮。 表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。**纯团体报名这一格旁边还有一个「队员」小按钮**(点开登记 / 修改这支队的队员,见 4.13);普通报名没有这个按钮。
**「运动员 / 队」这一列会限宽,长名单可以手动展开**:多人团体项目的队员名单(`张伟(A001)、李强(A002)…`)以前会把整列撑得很宽,现在**收起时限宽**(**14em ≈ 182px**,超出显示省略号);名单够长时(超过 **24 个「显示宽度」**——中文算 2、字母算 1)这一格右侧出现**逐行的「展开 / 收起」**小按钮,点开看全。**个人项目永远不会出现这个按钮**。展开状态**只在本机内存、按报名项记**,换项目 / 刷新即恢复收起;展开那一行会把列略微撑宽(成绩列右移约 **80px**),点回收起就恢复。
**「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。 **「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。
| 列 | 操作 | | 列 | 操作 |
@@ -142,6 +144,19 @@
**示例文本不是已填内容**:空格子里显示的 `例 1:06.33`(距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`)是**更淡的斜体灰字**,只是告诉你大概怎么写,**不代表这一格已经有成绩**。灰字不会被提交;点发布时如果那一格还是灰字,就按「还没录」自动跳过(本次不发布这一行),不会连累其他已填成绩的发布。过去示例文本长得和真实输入一样,容易看成「我已经填了」。 **示例文本不是已填内容**:空格子里显示的 `例 1:06.33`(距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`)是**更淡的斜体灰字**,只是告诉你大概怎么写,**不代表这一格已经有成绩**。灰字不会被提交;点发布时如果那一格还是灰字,就按「还没录」自动跳过(本次不发布这一行),不会连累其他已填成绩的发布。过去示例文本长得和真实输入一样,容易看成「我已经填了」。
**「搜姓名 / 号码布」搜索框(人多项目找人不费劲)**:位置在工具栏 **「批次」下拉右边**。
- **即时模糊匹配**:大小写不敏感、首尾空格与全角空格都当空白;号码布按**数字口径**——`007` 搜 `7`、`7` 搜 `007` 都能找到;
- **命中高亮**(只加一层淡底色);
- 输入框旁边显示 `找到 N 条 / 共 M 条`(**M 是当前批次口径的总行数**,不是搜索结果数);
- 清除有三种:输入框右侧的 × 按钮、在输入框里按 `Esc`、「清除搜索」按钮;搜不到时给明确的空状态与提示,不会只剩一张空表;
- 搜索范围是**行的姓名 / 队员名单 / 队员(含号码布)/ 行号码布**,**不含单位名**;
- **换小项 / 换组别会清空搜索**。
⚠️ 界面上也写着这行小字:**「搜索只影响表格显示,不影响发布范围与进度」** —— 筛完再点「发布」,发的仍然是**该小项 + 该组别里所有已填的成绩**(不是只发筛出来的这些);进度条的「已录 x/y」也仍然是**该批次总人数**。
⚠️ **粘贴跟着屏幕走**:搜索状态下按 `Ctrl+V` 是**按屏幕顺序**往下填的(**被筛掉、看不见的行不会被写到**)——**这是有意的**:否则第二条成绩会静默写到看不见的人身上。要按原来的行序粘贴,请先清除搜索。详见 4.3。
右侧「实时积分预览」显示:若现在发布,各单位积分的变化、最大分差、受影响单位数。 右侧「实时积分预览」显示:若现在发布,各单位积分的变化、最大分差、受影响单位数。
### 4.3 键盘流与批量录入 ### 4.3 键盘流与批量录入
@@ -151,14 +166,15 @@
| `Tab` | 跳到下一个单元格(浏览器原生焦点顺序) | | `Tab` | 跳到下一个单元格(浏览器原生焦点顺序) |
| `Enter` | 在成绩格之间依次移动 | | `Enter` | 在成绩格之间依次移动 |
| `Ctrl+Enter` | 存草稿 | | `Ctrl+Enter` | 存草稿 |
| `Alt+Enter` | 发布当前项目 | | `Alt+Enter` | 打开**发布窗口**(与工具栏「发布」同一个窗口,口径在里面二选一,见 4.5) |
| 「按成绩重排」 | 按成绩从小到大 / 从大到小重排表格行(名次仍以比较值为准) | | 「按成绩重排」 | 按成绩从小到大 / 从大到小重排表格行(名次仍以比较值为准) |
| 「下一格」 | 按钮方式移动焦点 | | 「下一格」 | 按钮方式移动焦点 |
**从 Excel 粘贴**:复制区域后,在成绩格按 `Ctrl+V`。 **从 Excel 粘贴**:复制区域后,在成绩格按 `Ctrl+V`。
- 两列及以上:第一列按单位名称 / 简称匹配行,最后一列作为成绩; - 两列及以上:第一列按单位名称 / 简称匹配行,最后一列作为成绩;
- 单列:从当前行开始向下依次填充。 - 单列:从当前行开始向下依次填充;
- ⚠️ **搜索状态下按屏幕顺序粘贴**:表格正在按搜索框筛选时,粘贴只落在**看得见的行**上(按屏幕顺序),被筛掉的行**不会被写到**——这是有意的:否则第二条成绩会静默写到看不见的人身上。要按原来的行序粘贴,先清除搜索(见 4.2)。
### 4.4 防呆校验 ### 4.4 防呆校验
@@ -180,10 +196,22 @@
1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。 1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。
- **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。 - **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。
2. **发布**:点击后弹出确认框,说明本次将发布多少人、还有多少人没成绩,以及影响的单位数与最大分差;确认后这批成绩立即上屏并重算积分榜(若管理员开了**延迟结算**,则只上屏成绩,名次与榜单等该组录完再更新,见 4.6、10.5)。 2. **发布**:点击后弹出**发布窗口**(标题形如 `发布该组:请选择本次的发布口径`),先在**两种口径里二选一**,再点「确认发布」:
| 选项 | 会发生什么 |
| --- | --- |
| **只更新名次(不计分、不发奖牌)** | **默认选中**:名次照常算出来、写进系统,但不产生积分、不发奖牌(分轮次比赛里给预赛 / 复赛用)。选项下面的说明就是 `本次只更新名次:不计分、不发奖牌,榜单分数不变。` |
| **按决赛成绩发布(重算名次并计分、发奖牌、榜单跟着更新)** | 与升级前的「勾上再发布」完全一样:算分、发牌、上榜 |
- 窗口里同时写明本次将发布多少人、还有多少人没成绩;选「按决赛成绩发布」时才会报「影响 N 个单位、最大分差 X 分」(只更新名次时报这两个数是假话,所以不报);
- 确认后这批成绩**立即上屏**:选计分那一项时同时重算积分榜;**只更新名次**时名次照常更新、榜单分数不变(若管理员开了**延迟结算**,则只上屏成绩,名次与榜单等该组录完再更新,见 4.6、10.5);
- **取消 = 什么都不发生**;**每次打开都回到默认那一项**(**不吃上一次的选择**)——这条是故意的:避免「换个小项再点发布,把预赛当成决赛算分」这种最贵的错法;
- `Alt+Enter`(见 4.3)走的是**同一个窗口**;
- 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存; - 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存;
- 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。 - 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。
> ⚠️ **成绩审核页(第 5 节)的入口形状不一样**:那里**仍然是发布按钮旁边的常驻勾选框**(勾上才计分发牌)。两页的口径与算法完全一致,只是录入页把口径挪进了弹窗——别以为审核页也改成弹窗了。
### 4.6 分批发布(跑一批发一批) ### 4.6 分批发布(跑一批发一批)
跑道只有 8 条、一个项目可能要分好几场跑时,**不需要等所有人都跑完**:每跑完一批就发一批,没跑到的先空着。 跑道只有 8 条、一个项目可能要分好几场跑时,**不需要等所有人都跑完**:每跑完一批就发一批,没跑到的先空着。
@@ -444,6 +472,8 @@
- **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜(延迟结算下同样等该组录完才更新名次与榜单),没录的保持未发布,补录后再发一次即可(分批发布,见 4.6); - **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜(延迟结算下同样等该组录完才更新名次与榜单),没录的保持未发布,补录后再发一次即可(分批发布,见 4.6);
- **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。 - **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。
> ⚠️ **本页的发布口径入口与录入页不同(两页别混)**:**本页保持原来的常驻勾选框** —— 发布按钮旁边的 **「以决赛成绩发布(勾选才计分发牌)」**,**默认不勾**,勾上才计分、发牌;旁边徽标随时显示当前是哪种口径(**只更新名次(不计分)** / **计分发布(算分发牌)**)。**录入页(4.5)则是点「发布」后在弹窗里二选一**(默认「只更新名次」)。两页发出去的请求口径完全一样,只是入口形状不同。
> 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效,但**没录成绩的条目会被跳过**——它既不挡发布,也不会被当成 0 分(防呆口径与录入页一致,见 4.4)。 > 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效,但**没录成绩的条目会被跳过**——它既不挡发布,也不会被当成 0 分(防呆口径与录入页一致,见 4.4)。
--- ---
@@ -598,14 +628,24 @@
- **保存**:改完模式或项目后点「保存」,提示「实时赛况设置已保存」后大屏按新配置显示;卡片底部注明「当前项目最多 2 个」; - **保存**:改完模式或项目后点「保存」,提示「实时赛况设置已保存」后大屏按新配置显示;卡片底部注明「当前项目最多 2 个」;
- 该卡片的「保存」需要**管理员**身份(见本节开头的权限说明);示例数据模式下不能保存。 - 该卡片的「保存」需要**管理员**身份(见本节开头的权限说明);示例数据模式下不能保存。
**在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次(浏览器切到后台时暂停,切回来立刻补一次)。每个实例一行,显示实例 ID、在线 / 离线徽标、「局域网浏览器」或「屏 N」标记、当前所在页、来源地址与最后心跳;服务端判定某个实例落后时还会多一个「落后」徽标(大屏可能在卡或网络差)。**本机用 Console 打开的大屏窗口、以及局域网设备打开 `/display` 连上的浏览器,都会出现在这里**——局域网浏览器没有屏号,那一栏显示「局域网浏览器」。离线实例会在服务端保留一段时间,方便排查它是什么时候掉的。 **在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次(浏览器切到后台时暂停,切回来立刻补一次)。每个实例一行,显示**设备名**、在线 / 离线徽标、「局域网浏览器」或「屏 N」标记、当前所在页、来源地址与最后心跳;服务端判定某个实例落后时还会多一个「落后」徽标(大屏可能在卡或网络差)。**本机用 Console 打开的大屏窗口、以及局域网设备打开 `/display` 连上的浏览器,都会出现在这里**——局域网浏览器没有屏号,那一栏显示「局域网浏览器」。离线实例会在服务端保留一段时间,方便排查它是什么时候掉的。
**设备名怎么来的(自动标签 + 手动改名)**:局域网浏览器连上来时会报一个**自动设备标签**,形如 `安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`,写法是 **系统 + 版本 · 浏览器 · 分辨率 · 形态(手机 / 平板 / 桌面)**,某个段取不到就跳过(例:`iPadOS 17 · Safari 17 · 2048×2732 @2x · 平板`、`Windows 10 · Edge 120 · 1920×1080 · 桌面`、`安卓 13 · 微信 8 · 1080×2340 @2.75x · 手机`)。
- ⚠️ **自动标签认不出「哪台机器」**:浏览器**没有**读主机名 / 设备名的 API(这是安全边界),所以**拿不到 `XIAOMI-PAD-05` 那种真机名**;`model` 字段只在**部分安卓浏览器**上可能有值。两台同型号设备靠自动标签是分不出来的;
- **手动改名**:每条有「改名」入口(**录入员及以上**可用,与切页同档)——点它就地出现一个输入框(示例 `二号楼平板`),填好按「保存」(`Enter` 也行);旁边的**「清除」**回到自动标签、**「取消」**什么都不改。名字**存在服务端**,**重启服务端与切换赛事项目都不丢**(输入框下方就写着这句),最长 **40 个字符**;
- 显示优先级是 **手动名 → 自动标签 → 那个随机实例 id**(id 降级成小字,排障时还在);读不出设备名时(老服务端 / 浏览器不报)列表退回显示 id,不会空着。**鼠标停在设备名上**还能看到排障要的那几行:设备标签、**原始 UA**、实例 id、来源 IP、屏号(取不到的整行不出现);
- **上限 200 条**:满了会**拒绝新增并提示先清理**(不会悄悄顶掉别的设备);
- 别名跟着**设备**走:设备太久没连、条目被服务端清理之后,**别名仍然留着**,同一台设备再连上来还是那个名字。
> **以前给局域网设备切页后列表会多出一条离线条目**(根因是翻页会补发一次握手,而浏览器没有稳定 id、服务端每次都新开一条实例):现在已经修好——**浏览器本机记住自己的实例 id**,服务端也做了兜底(同一条连接重复握手复用原实例),**不会再出现这种重复条目**。
**逐屏 / 逐设备切页**:在线大屏列表里每一行都在「当前页」旁带一个页面下拉和「切到此页」按钮,只把**这一块屏 / 这一台设备**切过去,其他屏不受影响;上方「切页」卡片里的「立即跳转」则是**所有大屏一起切**。离线实例的下拉与按钮是灰的,发不到它;点名的大屏一台都没收到指令时会明确提示「目标大屏已离线,请刷新在线列表」,不会谎报成功。列表读取失败时页面会提示「读取在线大屏失败」,并保留上一次的结果,不会把列表清空。 **逐屏 / 逐设备切页**:在线大屏列表里每一行都在「当前页」旁带一个页面下拉和「切到此页」按钮,只把**这一块屏 / 这一台设备**切过去,其他屏不受影响;上方「切页」卡片里的「立即跳转」则是**所有大屏一起切**。离线实例的下拉与按钮是灰的,发不到它;点名的大屏一台都没收到指令时会明确提示「目标大屏已离线,请刷新在线列表」,不会谎报成功。列表读取失败时页面会提示「读取在线大屏失败」,并保留上一次的结果,不会把列表清空。
**局域网大屏**:这张卡片是给「用别的设备当大屏」准备的——把主机在局域网里可用的地址拼成完整链接,手机 / 平板 / 电视浏览器打开就是一块只读大屏(卡片上就写着「在别的设备浏览器打开下面的地址,就是一块大屏。」)。 **局域网大屏**:这张卡片是给「用别的设备当大屏」准备的——把主机在局域网里可用的地址拼成完整链接,手机 / 平板 / 电视浏览器打开就是一块只读大屏(卡片上就写着「在别的设备浏览器打开下面的地址,就是一块大屏。」)。
- 卡片右上角徽标显示「N 个可用地址」;每个地址一行,**点链接**直接在新标签页打开,点旁边的**复制**按钮把完整地址(形如 `http://192.168.1.5:19540/display`)复制到剪贴板,按钮会短暂变成「已复制」; - 卡片右上角徽标显示「N 个可用地址」;每个地址一行,**点链接**直接在新标签页打开,点旁边的**复制**按钮把完整地址(形如 `http://192.168.1.5:19540/display`)复制到剪贴板,按钮会短暂变成「已复制」;
- **第一个地址带一个二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址; - **第一个地址带一个常驻二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址;**其余每条地址右侧多一个「二维码」按钮**:**点一下临时显示**那条地址的码,**再点一次 / 按 Esc / 点空白处**都能收起,**同一时刻只留一个**(屏幕小的时候不必为别的网段留一块地方);
- **地址分三类**,卡片会照实把可用地址都列出来(排除的只有回环、未指定、组播、广播这四类对本机之外没有意义的地址): - **地址分三类**,卡片会照实把可用地址都列出来(排除的只有回环、未指定、组播、广播这四类对本机之外没有意义的地址):
- **内网地址**(`192.168.x.x`、`10.x.x.x`、`172.16–31.x.x` 这类):路由器 / 交换机分配或手工设好的地址,也是最常见的现场用法,**排在最前**; - **内网地址**(`192.168.x.x`、`10.x.x.x`、`172.16–31.x.x` 这类):路由器 / 交换机分配或手工设好的地址,也是最常见的现场用法,**排在最前**;
- **全局地址**:不在上面这些网段里的地址(运营商专线等),能用就照常列出来,只是**排在私网地址之后**; - **全局地址**:不在上面这些网段里的地址(运营商专线等),能用就照常列出来,只是**排在私网地址之后**;
@@ -1202,7 +1242,7 @@
> 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。 > 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。
> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。 > 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(**第一条是常驻二维码,其余每条都能点「二维码」按钮临时出码**,见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(带一个自动设备标签,可手动改名),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。
### 17.2 轮播与锁定 ### 17.2 轮播与锁定
@@ -1277,3 +1317,18 @@
**Q:为什么有些页面 / 按钮看不到或不可用?** **Q:为什么有些页面 / 按钮看不到或不可用?**
权限按角色控制:访客只能只读;变更历史、日志、系统设置、备份、用户管理、规则包与赛事项目包导入需要相应角色。请用对应账号登录。 权限按角色控制:访客只能只读;变更历史、日志、系统设置、备份、用户管理、规则包与赛事项目包导入需要相应角色。请用对应账号登录。
**Q:为什么点「发布」还得多选一次?**
因为**这一次发布的「口径」要现选**(录入页原来的常驻勾选框已经取消)。窗口里二选一,**默认是「只更新名次(不计分、不发奖牌)」**,「按决赛成绩发布」才计分发牌、榜单跟着更新。
- **每次打开都回到默认那一项**,**不吃上一次的选择**——这是故意的:避免「换个小项再点发布,把预赛当成决赛算分」这种最贵的错法;
- **取消 = 什么都不发生**;`Alt+Enter` 走同一个窗口;
- ⚠️ **成绩审核页不一样**:那里仍然是发布按钮旁边的**常驻勾选框**(勾上才计分发牌)。两页口径一样,只是入口形状不同(见 4.5、第 5 节)。
**Q:人多的项目(3 公里、5 公里)里怎么快速找到某个人?**
在「成绩录入」页工具栏 **「批次」下拉右边**的搜索框里输入**姓名或号码布**:边打边筛(大小写不敏感、首尾与全角空格都当空白),号码布按**数字口径**比——`007` 搜 `7`、`7` 搜 `007` 都能找到;命中的字会**高亮**,旁边显示 `找到 N 条 / 共 M 条`(M 是当前批次口径的总行数)。清除有三种:输入框的 × 按钮、在输入框里按 `Esc`、「清除搜索」;**换小项 / 换组别会自动清空搜索**。搜索范围是姓名 / 队员名单 / 队员(含号码布)/ 行号码布,**不含单位名**。
⚠️ 两条要记住:
1. **搜索只影响表格显示,不影响发布范围与进度**(界面上也写着这行小字):筛完再点「发布」,发的仍是这个小项 + 这个组别里**所有已填的成绩**;进度条的「已录 x/y」也仍是**该批次总人数**;
2. **粘贴跟着屏幕走**:搜索状态下按 `Ctrl+V` 只往**看得见的行**填(被筛掉的行不会被写到,免得第二条成绩静默写到看不见的人身上);要按原来的行序粘贴,请先清除搜索。
+120 -2
View File
@@ -6,6 +6,123 @@
--- ---
## dev_0.5.15
### 一句话
**没有任何数据迁移:不改一行已有数据、不用重配、不用重新导入规则包**,已经录过、发过的成绩与积分**不用重发、不用重算**——新版起来之后,**大屏下一次刷新就显示对**。这一版**修两处现场看得见的错**(大屏上纯团体项目的名次全是 `-`,见 ①;分项成绩页第 3 栏看不到第 1 名,见 ②),另有五处**新增 / 改口径**(运动员列可限宽、发布改成弹窗里二选一、大屏设备名可读且能改名、局域网地址条目都能出二维码、录入页多了搜索框,见 ③–⑦)。
### ① 修:大屏上「纯团体项目」的名次全是 `-`
**先说清楚它不是什么**:**不是算分错、也不是团体名次没算出来**——名次早就在库里,**导出件与积分明细下钻一直是对的**,录入页也一直看得见。**只是大屏那两个读者没读到它。**
**为什么只有大屏看不到**:纯团体项目的名次(例如团体 4×100 接力的单位名次)**只落在「通道名次」上**(发布时算出来的那份名次);而成绩行上的「个人名次」这一列**是有意收窄的**——它只记个人名次,**对团体主体一直是空的**。大屏的两个读者(快照的「最新成绩 / 分项成绩 / 底栏」与「实时赛况」)原来**只读「个人名次」、没有兜底**,于是团体行画出来就是一个 `-`;而**导出件与积分明细下钻早就有兜底**——所以当时是「导出件有名次、屏幕上没有」。
**现在会怎样**:这两个读者都改成 **「个人名次」优先、为空时回落到「通道名次」**。**算分与写回一行没动**(名次怎么算、流水怎么写、榜单怎么更新全都没变)。
> ⚠️ **为什么是「个人名次优先」而不是「通道名次优先」**:**「只更新名次」的发布只写「个人名次」、不重建通道名次**(见 `dev_0.5.8` 那一节)。要是让通道名次优先,个人项目在那种发布之后会**显示上一次的旧名次**。反过来「个人名次为空才回落」正好补上团体主体——团体名次本来就不写「个人名次」。
**现场要强调的**:**已经发布过的数据不用重发、不用重算**,新版起来大屏下一次刷新就显示出来;名次在录入页本来就看得见,这一版只是把同一份名次也画到大屏上。
⚠️ **仍未覆盖的一种情形(如实写)**:**只在「团体名次」卡片里按单位录了名次、成绩行上没填**的那类项目(例如拔河只用卡片录),名次只存在「按单位录入的团体名次」里、**没有对应的成绩行**,大屏上**仍然显示 `-`**。这一版只修「名次落在通道名次上」的那一类。
### ② 修:分项成绩页「第 3 栏看不到第 1 名」
**症状的形状很特别**:同样几个项目,**放到前两栏就没事,落到第 3 栏就看不到第 1 名**——这个毛病**跟着栏位走、不跟着项目走**。
**真正的原因**:服务端快照里「最新成绩」的窗口原来是**按行取 20 行**,而大屏上是「一组一栏」——窗口的切口**只能落在最旧的那一栏(通常就是第 3 栏)内部**,把那一组里**建号最早的行**(手录项目通常就是第 1 名)挤了出去。
**现在会怎样**:窗口改成 **「最近 8 组 × 每组 10 行」**——先按每组最近一次发布时间取**最近 8 组**,再在每组里**按名次保底取前 10 行**。名次的切口不再落在某一栏内部,**第 3 栏从第 1 名开始画**。**底栏那条「最新完成项目」也一起吃这个修复。**
**取舍(要写清)**:单组**超过 10 行**的部分**不再下发**。前端每栏只画 6 行、「实时赛况」上限也是 10 行,所以三个页面都看不出来;⚠️ **将来若把每栏行数上限调到 10 以上,必须同步调整这个「每组 10 行」**(这两处是一对)。
### ③ 新:录分列表「运动员 / 队」这一列可限宽 + 手动展开
多人团体项目的名单(`张伟(A001)、李强(A002)…`)以前会把整列撑得很宽。现在:
- **收起时限宽**(**14em ≈ 182px**,超出显示省略号);
- 文本够长时(超过 **24 个「显示宽度」**——中文算 2、字母算 1),这一格右侧出现**逐行的「展开 / 收起」**小按钮;
- **个人项目永远不会出现这个按钮**;
- 展开状态**只在本机内存**、**按报名项记**,换项目 / 刷新即恢复收起。
**取舍**:展开那一行会把列略微撑宽(成绩列右移约 **80px**),点回收起就恢复。
### ④ 改:录入页「以决赛成绩发布」从勾选框改成发布弹窗里选
**成绩录入页**:工具栏上那个常驻勾选框(与旁边的口径徽标)**已移除**。现在**点「发布」弹出一个窗口**(标题形如 `发布该组:请选择本次的发布口径`),里面**二选一**:
| 选项 | 会发生什么 |
| --- | --- |
| **只更新名次(不计分、不发奖牌)** | **默认选中**——名次照常算出来、写进系统,但不产生积分、不发奖牌 |
| **按决赛成绩发布(重算名次并计分、发奖牌、榜单跟着更新)** | 与以前「勾上再发布」完全一样:算分、发牌、上榜 |
- 同一句口径说明也写在选项下面(`本次只更新名次:不计分、不发奖牌,榜单分数不变。` / `本次按决赛成绩发布:算分、发奖牌、榜单跟着更新。`);
- **取消 = 什么都不发生**;
- **每次打开都回到默认**(**不吃上一次的选择**)——这条是故意的:避免「换个小项再点发布,把预赛当成决赛算分」这种最贵的错法;
- `Alt+Enter` 快捷键走**同一个窗口**;
- 窗口里同时写明本次将发布多少人、还有多少人没成绩;选「按决赛成绩发布」时才会报「影响 N 个单位、最大分差 X 分」(只更新名次时报这两个数是假话)。
⚠️ **成绩审核页保持原来的常驻勾选框不变**(两个页面口径不同,别以为审核页也变了)——见《使用手册》4.5 与第 5 节。
### ⑤ 新:大屏控制页的「在线大屏」列表 —— 可读设备名 + 手动重命名 + 修一个重复条目
**自动设备标签**(局域网浏览器一连上来就带着):
| 设备 | 标签 |
| --- | --- |
| 安卓手机 | `安卓 13 · Chrome 120 · 1080×2400 @3x · 手机` |
| iPad | `iPadOS 17 · Safari 17 · 2048×2732 @2x · 平板` |
| Windows 电脑 | `Windows 10 · Edge 120 · 1920×1080 · 桌面` |
| 微信内置浏览器 | `安卓 13 · 微信 8 · 1080×2340 @2.75x · 手机` |
写法是 **系统 + 版本 · 浏览器 · 分辨率 · 形态(手机 / 平板 / 桌面)**,缺的段自动跳过。
⚠️ **做不到什么,必须如实说清**:浏览器**没有**读主机名 / 设备名的 API(这是安全边界),所以**拿不到那种「XIAOMI-PAD-05」的真机名**;`model` 字段只在**部分安卓浏览器**上可能有值。**自动标签只是辅助区分**,两台同型号设备还得靠**手动重命名**或**来源 IP** 分辨。
**手动重命名**:列表每条有「改名」入口(**录入员及以上**可用,与切页同档):
- 点它**就地出现一个输入框**(示例 `二号楼平板`),按「保存」或 `Enter` 提交;旁边的**「清除」**回到自动标签、**「取消」**什么都不改。名字**存在服务端**,**重启与切赛事都不丢**(**最长 40 个字符**);
- 显示优先级:**手动名 → 自动标签 → 那个随机 id**(id 降级成小字,排障时还在);
- 上限 **200 条**,**满了会拒绝新增并提示先清理**(不会悄悄顶掉别的设备);
- 别名跟着**设备**走:设备太久没连、条目被清理之后,**别名仍留着**,同一台设备再连上来还是那个名字。
**修:给某台设备切页之后,列表底部会多出一个该设备的离线条目**。根因是**翻页会补发一次握手**,而浏览器**没有稳定 id**、服务端每次都新开一条实例。现在**浏览器本机记住自己的实例 id**,服务端也做了兜底(同一条连接重复握手复用原实例)——**升级后不会再出现这种重复条目**。
### ⑥ 新:局域网地址条目都能出二维码了
以前**只有第一条地址**自动带二维码。现在**其余每条**多了一个「**二维码**」按钮:**点一下临时显示**那条地址的码,**再点一次 / 按 Esc / 点空白处**都能收起,**同一时刻只留一个**。**第一条那条常驻二维码与「复制链接」都没变。**
### ⑦ 新:成绩录入页加了「搜姓名 / 号码布」搜索框
3 公里、5 公里那种人多的项目,找人不费劲了。位置在**工具栏「批次」下拉右边**:
- **即时模糊匹配**:大小写不敏感、首尾空格与全角空格都当空白;号码布支持数字口径——**`007` 搜 `7`、`7` 搜 `007` 都能找到**;
- **命中高亮**(只加一层淡底色);
- 提示 `找到 N 条 / 共 M 条`(**M 是批次口径的总行数**);
- 清除有三种:× 按钮、输入框里按 Esc、「清除搜索」按钮;搜不到时给明确的空状态与提示;
- 搜索范围是**行的姓名 / 队员名单 / 队员(含号码布)/ 行号码布**,**不含单位名**;
- 换小项 / 换组别会**清空搜索**。
⚠️ **「搜索只影响表格显示,不影响发布范围与进度」**(界面上也有这行小字):筛完再点发布,发的仍是**该小项 + 该组别里所有已填的成绩**;进度条的「已录 x/y」也仍是**该批次总人数**。
⚠️ **粘贴跟着屏幕走**:搜索状态下按**屏幕顺序**粘贴(**隐藏的行不会被写到**)——**这是有意的**,否则第二条成绩会静默写到看不见的人身上。
### ⑧ 升级后 5 分钟核对
1. 打开一个**纯团体项目**(例如团体 4×100 接力)已发布成绩的大屏页面:**分项成绩、实时赛况、底栏「最新完成项目」三处都应当有名次**,不再是 `-`(**不要重发、不要重算**,刷新一下大屏即可);
2. 看**分项成绩页的第 3 栏**:**应当从第 1 名开始**(升级前第 1 名会被挤出这一栏);
3. 到「成绩录入」页选一个**多人团体项目**:**「运动员 / 队」这一列收起时是限宽的**,长名单右侧有「展开 / 收起」;再看一个**个人项目**,那里不应当有这个按钮;
4. 点一次「发布」:应弹出**二选一**的窗口,**默认是「只更新名次(不计分、不发奖牌)」**,按「取消」什么都不发生;想计分发牌就选第二项再确认。⚠️ 到**成绩审核页**看一眼:那里**还是原来的常驻勾选框**;
5. 到「大屏控制 → 在线大屏」:局域网设备的条目应当**写着 `安卓 13 · Chrome 120 · … · 手机` 这样的可读标签**(不是一串随机 id),点「改名」能起个自己的名字;再给某台设备切一次页,**列表底部不该多出离线条目**;
6. 到「大屏控制 → 局域网大屏」:**每条地址都有「二维码」按钮**,点开再点一次能收起(第一条的常驻二维码照旧);
7. 到「成绩录入」页找一个人多的项目,在**「批次」下拉右边**输入姓名或号码布(`007` 与 `7` 互通):应看到 `找到 N 条 / 共 M 条` 与高亮;**「发布」窗口里的人数与进度条数字不受搜索影响**。
### ⑨ 详细说明在哪
《使用手册》4.2(录入页搜索框、「运动员 / 队」列的收起 / 展开)、4.3(粘贴跟着屏幕走)、4.5(发布的弹窗二选一口径)、第 5 节(审核页仍是常驻勾选框)、第 9 节(大屏设备标签 / 改名 / 地址二维码按钮、重复条目已修);《常见问题》Q105–Q107;《部署指南》7.5(局域网地址与二维码)。
---
## dev_0.5.14 ## dev_0.5.14
### 一句话 ### 一句话
@@ -213,7 +330,8 @@
### ① 新增:「以决赛成绩发布」开关 ### ① 新增:「以决赛成绩发布」开关
- **位置**:成绩录入页(以及**成绩审核页**)的「发布」按钮旁边,一个勾选框:**「以决赛成绩发布(勾选才计分发牌)」**,**默认不勾**。旁边有一个小徽标随时告诉你当前是哪种口径:**只更新名次(不计分)** / **计分发布(算分发牌)**。 - **位置**:成绩**审核**页的「发布」按钮旁边,一个勾选框:**「以决赛成绩发布(勾选才计分发牌)」**,**默认不勾**。旁边有一个小徽标随时告诉你当前是哪种口径:**只更新名次(不计分)** / **计分发布(算分发牌)**。
> ⚠️ **本条的位置后来变过一处,口径以 `dev_0.5.15` 那一节为准**:**成绩录入页**上这个常驻勾选框与徽标**已移除**,改成点「发布」时**弹窗二选一**(默认「只更新名次」);**成绩审核页保持原样的常驻勾选框不变**。算法、写库行为与在榜口径提示**一个字节都没变**,只换了入口形状(见上面 `## dev_0.5.15` 的 ④)。
- **不勾(默认)**:这次发布**只更新名次** —— 名次会照常算出来、写进系统(预赛也要排名次),但**不产生积分、不发奖牌**。 - **不勾(默认)**:这次发布**只更新名次** —— 名次会照常算出来、写进系统(预赛也要排名次),但**不产生积分、不发奖牌**。
- **勾上**:与升级前**完全一样** —— 算分、发牌、上榜。 - **勾上**:与升级前**完全一样** —— 算分、发牌、上榜。
- **勾选是"这一次发布"的口径,不是页面设置**:发完自动复位(免得您换到别的小项时,把预赛当成决赛算分)。 - **勾选是"这一次发布"的口径,不是页面设置**:发完自动复位(免得您换到别的小项时,把预赛当成决赛算分)。
@@ -232,7 +350,7 @@
### ③ 两条使用上的提醒 ### ③ 两条使用上的提醒
1. **两个发布入口都有这个开关**:成绩录入页与成绩审核页 —— 从哪一页发布口径都一样(否则从审核页发布预赛会被悄悄算成分)。 1. **两个发布入口口径一致**:成绩录入页与成绩审核页 —— 从哪一页发布口径都一样(否则从审核页发布预赛会被悄悄算成分)。⚠️ `dev_0.5.15` 起录入页的开关改成**发布弹窗里的二选一**(审核页仍是常驻勾选框),见上面 `## dev_0.5.15` 的 ④。
2. **只在"分轮次"的项目上用**:一次就比完的项目照旧**勾上**发布(与以前一样),别漏勾 —— 漏勾的话名次会更新,但**不会计分**,榜上不会有这一项的分(界面上有徽标和在榜口径提示,发布前看一眼就不会错)。 2. **只在"分轮次"的项目上用**:一次就比完的项目照旧**勾上**发布(与以前一样),别漏勾 —— 漏勾的话名次会更新,但**不会计分**,榜上不会有这一项的分(界面上有徽标和在榜口径提示,发布前看一眼就不会错)。
### ④ 升级后 5 分钟核对 ### ④ 升级后 5 分钟核对
+40
View File
@@ -761,3 +761,43 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
3. **「队员名单」只增不减**:导出时会把界面上后来补进这支队的队员一并补上,所以重导后这支队的队员**不会少**,但个别名单的**顺序 / 写法**可能与最初上传的那张表不完全一样。 3. **「队员名单」只增不减**:导出时会把界面上后来补进这支队的队员一并补上,所以重导后这支队的队员**不会少**,但个别名单的**顺序 / 写法**可能与最初上传的那张表不完全一样。
**一句话**:给人看的用「导出报名名单」;**要拿回去重导,用「导出(汇总·导入格式)」或「导出(分小项·导入格式)」**,导出的文件**不要改表头、不要插删列**。详见[使用手册 §11.1](./使用手册.md)与[报名名单导入规范 · 第九节](./报名名单导入规范.md#九导出的名单怎么导回去)。 **一句话**:给人看的用「导出报名名单」;**要拿回去重导,用「导出(汇总·导入格式)」或「导出(分小项·导入格式)」**,导出的文件**不要改表头、不要插删列**。详见[使用手册 §11.1](./使用手册.md)与[报名名单导入规范 · 第九节](./报名名单导入规范.md#九导出的名单怎么导回去)。
**Q105:大屏上某个团体项目的名次显示 `-`,是不是没算出来?**
**先说结论:名次早就算出来了——导出件与积分明细里一直是对的,以前只是大屏那一处没读它。新版起来刷新一下大屏就有了,不用重发、不用重算。**
以前会这样的原因:纯团体项目的名次(例如团体 4×100 接力的单位名次)只写在**发布时算出来的那份名次(通道名次)**上;而成绩行上的「个人名次」这一列是**有意收窄的**——它只记个人名次,**对团体主体一直是空的**。大屏原来只读「个人名次」、没有兜底,于是团体行画出来就是一个 `-`;导出件与积分明细下钻**早就带了兜底**,所以当时是「导出的文件有名次、屏幕上没有」。录入页也一直看得见名次。
现在大屏的两个读者(分项成绩 / 底栏「最新完成项目」,以及实时赛况)都改成 **「个人名次」优先、为空时回落到「通道名次」**,**算分与写回一行没动**。
- **已经发布过的数据不用重发、不用重算**:新版起来大屏下一次刷新就显示出来;
- **为什么不干脆让「通道名次」优先**:「**只更新名次**」的发布只写「个人名次」、**不重建通道名次**,通道名次一优先,个人项目在那种发布之后会显示**上一次的旧名次**(见使用手册 4.5 / 5 与《升级说明》`dev_0.5.15` 的 ④);
- ⚠️ **还有一类项目仍然显示 `-`(已知限制)**:**只在「团体名次」卡片里按单位录了名次、成绩行上一个数都没填**的项目(例如拔河只用卡片录)——那类名次只存在「按单位录入的团体名次」里、**没有对应的成绩行**,大屏上仍然没有名次可画(团体名次本身照常算分,见 Q95)。要上屏就得在成绩行上也有成绩。
详见《升级说明》`dev_0.5.15` 的 ①。
**Q106:分项成绩页第 3 栏看不到第 1 名、名次看着是跳的,是不是算错了?**
**先说结论:没算错,是那一栏被「截断」过。这个毛病跟着栏位走、不跟着项目走——同一批项目放在前两栏就没事,落到第 3 栏就少人。**
原因:服务端快照里「最新成绩」的窗口原来是**按行取 20 行**,而屏上是「一组一栏」——窗口的切口**只能落在最旧的那一栏(通常就是第 3 栏)内部**,把那一组里**建号最早的行**(手录项目通常就是第 1 名)挤了出去;剩下能画的行名次自然是跳的。
现在窗口改成 **「最近 8 组 × 每组 10 行」**:先按每组最近一次发布时间取**最近 8 组**,再在每组里**按名次保底取前 10 行**。名次的切口不再落在某一栏内部,**第 3 栏从第 1 名开始画**;**底栏那条「最新完成项目」也一起吃这个修复**。
- ⚠️ **取舍**:单组**超过 10 行**的部分**不再下发**。前端每栏只画 6 行、「实时赛况」上限也是 10 行,所以三个页面都看不出来;**将来若把每栏的行数上限调到 10 以上,必须同步调这个「每组 10 行」**;
- 想核对某一组的**完整**名次:看「导出 → 结果」里的分项成绩表,或切到大屏的「实时赛况」页(当前项目下发的是完整名单,本来就不受这个窗口限制)。
详见《升级说明》`dev_0.5.15` 的 ②。
**Q107:大屏控制页「在线大屏」里的设备名看不懂、两台一样的平板分不出来,能改名吗?**
能。列表里每条都有「**改名**」入口(**录入员及以上**可用,与切页同一档)。
- 现在显示的是**自动设备标签**,形如 `安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`——写法是 **系统 + 版本 · 浏览器 · 分辨率 · 形态(手机 / 平板 / 桌面)**,取不到的段会自动跳过(例如 `iPadOS 17 · Safari 17 · 2048×2732 @2x · 平板`、`Windows 10 · Edge 120 · 1920×1080 · 桌面`、`安卓 13 · 微信 8 · 1080×2340 @2.75x · 手机`);
- ⚠️ **自动标签认不出「哪台机器」**:浏览器**没有**读主机名 / 设备名的 API(这是安全边界),所以**拿不到 `XIAOMI-PAD-05` 那种真机名**;两台同型号设备只能靠**手动改名**或**来源 IP** 分辨;
- **怎么改**:点该行的「改名」→ 就地填一个现场看得懂的名字(例如 `二号楼平板`)→「保存」(`Enter` 也可);旁边的**「清除」**回到自动标签、**「取消」**什么都不改。名字**存在服务端**,**重启服务端与切换赛事项目都不丢**(最长 40 个字符)。显示优先级是 **手动名 → 自动标签 → 那个随机实例 id**(id 降级成小字,排障时还在);鼠标停在设备名上能看到设备标签、**原始 UA**、实例 id、来源 IP、屏号(取不到的整行不出现);
- **上限 200 条**:满了会**拒绝新增并提示先清理**,不会悄悄顶掉别的设备;
- 别名跟着**设备**走:设备太久没连、条目被服务端清理之后,**别名仍然留着**,同一台设备再连上来还是那个名字;
- **以前给某台设备切页后,列表底部会多出一个该设备的离线条目**(根因是翻页会补发一次握手,而浏览器没有稳定 id、服务端每次都新开一条实例),**现在已经修好**:浏览器本机记住自己的实例 id,服务端也做了兜底(同一条连接重复握手复用原实例),升级后不会再出现这种重复条目。
顺带一句:「局域网大屏」卡片里**每一条地址**都能出二维码了——第一条是常驻二维码,其余每条点「**二维码**」按钮临时出码(再点一次 / 按 Esc / 点空白处收起,同一时刻只留一个),不用为别的网段再手敲地址。详见《使用手册》第 9 节、《部署指南》7.5。
+3 -3
View File
@@ -382,7 +382,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
管理端「大屏控制」页有**显示器**卡片,可以给指定显示器单独开窗或收窗。这张卡片只在**从服务管理窗口点「打开服务端管理面板」打开的那个窗口**里可用;用浏览器直接打开管理面板时,卡片会提示此功能不可用。 管理端「大屏控制」页有**显示器**卡片,可以给指定显示器单独开窗或收窗。这张卡片只在**从服务管理窗口点「打开服务端管理面板」打开的那个窗口**里可用;用浏览器直接打开管理面板时,卡片会提示此功能不可用。
同一页右侧还有「**局域网大屏**」卡片:把本机可用的局域网地址拼成完整链接(可点开、可一键复制),第一个地址还带二维码,别的设备扫码即可当大屏用。卡片需**录入员及以上**登录才看得到,用法见《使用手册》第 9 节。 同一页右侧还有「**局域网大屏**」卡片:把本机可用的局域网地址拼成完整链接(可点开、可一键复制),**第一个地址带一个常驻二维码,其余每条也能点「二维码」按钮临时出码**(点一下显示、再点一次 / 按 Esc / 点空白处收起,同一时刻只留一个),别的设备扫码即可当大屏用。卡片需**录入员及以上**登录才看得到,用法见《使用手册》第 9 节。
- 列出本机每块显示器(名称、分辨率、缩放、是否主显示器、当前有没有大屏窗口、是全屏还是窗口化); - 列出本机每块显示器(名称、分辨率、缩放、是否主显示器、当前有没有大屏窗口、是全屏还是窗口化);
- 先在「窗口页面」里选页面,再对某块屏点「创建全屏窗口」或「创建窗口(窗口化)」;已经开着窗口的屏显示「聚焦窗口」与「关闭窗口」; - 先在「窗口页面」里选页面,再对某块屏点「创建全屏窗口」或「创建窗口(窗口化)」;已经开着窗口的屏显示「聚焦窗口」与「关闭窗口」;
@@ -393,7 +393,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
**「局域网大屏」卡片:怎么拿到地址(两条路)** **「局域网大屏」卡片:怎么拿到地址(两条路)**
1. **卡片自动检测**:「大屏控制 → 局域网大屏」卡片会列出主机上可用的地址,直接点开或扫码;第一个地址(也就是二维码那一条)就是现场最可能要用的; 1. **卡片自动检测**:「大屏控制 → 局域网大屏」卡片会列出主机上可用的地址,直接点开或扫码;**第一个地址(常驻二维码那一条)就是现场最可能要用的**,其余地址要多一个「二维码」按钮、点一下才出码;
2. **`ipconfig` 手填**:自动检测不到、或者列出来的不是你那张网卡时,在管理员 cmd / PowerShell 里跑 `ipconfig`,把对应网卡的 **IPv4 地址**抄进卡片下方的「手动添加地址」再点「添加」——手填的排在第一条、带「手动」徽标,记在浏览器本地,刷新不丢(最多 10 个,可「删除」)。 2. **`ipconfig` 手填**:自动检测不到、或者列出来的不是你那张网卡时,在管理员 cmd / PowerShell 里跑 `ipconfig`,把对应网卡的 **IPv4 地址**抄进卡片下方的「手动添加地址」再点「添加」——手填的排在第一条、带「手动」徽标,记在浏览器本地,刷新不丢(最多 10 个,可「删除」)。
**三类地址的含义**(卡片按类别判定,只排除回环 / 未指定 / 组播 / 广播这四类对本机之外没有意义的地址): **三类地址的含义**(卡片按类别判定,只排除回环 / 未指定 / 组播 / 广播这四类对本机之外没有意义的地址):
@@ -456,7 +456,7 @@ netsh advfirewall firewall add rule name="Scoreboard 19540" dir=in action=allow
http://<主机IP>:19540/display http://<主机IP>:19540/display
``` ```
- 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(第一个地址),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到); - 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(**第一条是常驻二维码,其余每条点「二维码」按钮也能临时出码**),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到);
- 需要主机设置固定 IP,且设备与主机在同一局域网; - 需要主机设置固定 IP,且设备与主机在同一局域网;
- **主机防火墙要放行入站 19540**(默认端口):**「为所有用户安装」会自动放行**(那一档提权时装好规则),不用再管;**「仅为我安装」不加规则、必须手工放行一次**(命令与说明见 7.5)。装的时候选哪一档,取决于 3.1 里那几行 —— 新版安装器一启动就申请管理员,但只有「为所有用户」那一趟才顺手改防火墙; - **主机防火墙要放行入站 19540**(默认端口):**「为所有用户安装」会自动放行**(那一档提权时装好规则),不用再管;**「仅为我安装」不加规则、必须手工放行一次**(命令与说明见 7.5)。装的时候选哪一档,取决于 3.1 里那几行 —— 新版安装器一启动就申请管理员,但只有「为所有用户」那一趟才顺手改防火墙;
- 安卓电视一般常亮,无需额外防熄屏; - 安卓电视一般常亮,无需额外防熄屏;
+5
View File
@@ -551,6 +551,11 @@ func loadEventRowsWithMembers(db *sql.DB, eventID, categoryID int64, includeDraf
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道 // channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
// 落不了库)回落到 results.rank,保持名次列不空白。 // 落不了库)回落到 results.rank,保持名次列不空白。
// //
// ⚠️ 这段子查询与 realtime/snapshot.go 的 mainChannelRankJoin、httpapi/live_events.go 的同名常量
// 同源(大屏两个读者),改一处要改三处。优先级**故意不同**:导出这里是 cr 优先(既有口径,
// 由 TestEventRowsUseMainChannelRank 钉住),大屏那边是 results.rank 优先——「只更新名次」的发布
// 只写 results.rank、不重建 channel_ranks,大屏 cr 一优先就会显示上一次的旧名次。
//
// 关联用「cr.id = 该报名在该通道下的最小 id」而不是直接按 (通道, 报名, 组别) JOIN: // 关联用「cr.id = 该报名在该通道下的最小 id」而不是直接按 (通道, 报名, 组别) JOIN:
// 团体报名挂上队员之后(devdocs/20),一条报名在同一通道下有 1 + N 行名次(报名项自己那份 // 团体报名挂上队员之后(devdocs/20),一条报名在同一通道下有 1 + N 行名次(报名项自己那份
// 加每个队员一份个人副本),直接 JOIN 会把这条报名在成绩表里印 N+1 遍。 // 加每个队员一份个人副本),直接 JOIN 会把这条报名在成绩表里印 N+1 遍。
@@ -0,0 +1,706 @@
package httpapi_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/coder/websocket"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// ---------- 别名用例的实时层替身 ----------
//
// 不用 review_fixes_test.go 里那个 fakeRealtime:这些用例要在两次请求**之间**改写实例列表
// (模拟「那台设备被 TTL 清理掉了」),而它的 instances 字段没有并发保护。这里给列表加锁,
// 写入与读取之间才有确定的同步关系。
type aliasFakeRealtime struct {
mu sync.Mutex
instances []realtime.Instance
}
func (f *aliasFakeRealtime) Broadcast(changes []realtime.Change) {}
func (f *aliasFakeRealtime) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error) {
return nil, nil
}
func (f *aliasFakeRealtime) Instances() []realtime.Instance {
f.mu.Lock()
defer f.mu.Unlock()
return append([]realtime.Instance(nil), f.instances...)
}
// set 整批替换实例列表(用例在请求之间调用)。
func (f *aliasFakeRealtime) set(list ...realtime.Instance) {
f.mu.Lock()
defer f.mu.Unlock()
f.instances = list
}
// aliasRef 是别名用例专用的测试环境:recorder / admin 两个账号已建好。
type aliasRef struct {
env *testEnv
fake *aliasFakeRealtime
}
func newAliasEnv(t *testing.T) *aliasRef {
t.Helper()
fake := &aliasFakeRealtime{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
env.createUser("recorder", "recorder-pass", "recorder")
env.createUser("boss", "boss-pass", "admin")
return &aliasRef{env: env, fake: fake}
}
func (r *aliasRef) login(t *testing.T, username, password string) *http.Client {
t.Helper()
client, _ := r.env.client()
r.env.login(client, username, password)
return client
}
// putAlias 打一次改名接口。
func (r *aliasRef) putAlias(t *testing.T, client *http.Client, id, alias string) (int, envelope) {
t.Helper()
return r.env.do(client, http.MethodPut,
"/api/display/instances/"+id+"/alias", map[string]any{"alias": alias}, true)
}
// rawAliasSetting 直接读系统库里的原始设置值(绕开接口,验证「真的落盘了」)。
func rawAliasSetting(t *testing.T, db *sql.DB) string {
t.Helper()
var raw string
err := db.QueryRow(`SELECT value FROM settings WHERE key = 'display.instance_aliases'`).Scan(&raw)
if err == sql.ErrNoRows {
return ""
}
if err != nil {
t.Fatalf("读取别名设置: %v", err)
}
return raw
}
// ---------- 设置 / 覆盖 / 清除 ----------
func TestDisplayAliasSetReturnsInstance(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{
ID: "browser-1", Online: true, Page: "standings",
Device: "安卓 13 · Chrome 120 · 手机", DeviceRaw: "Mozilla/5.0 (Linux; Android 13)",
})
client := r.login(t, "recorder", "recorder-pass")
status, env := r.putAlias(t, client, "browser-1", "二号楼平板")
if status != http.StatusOK {
t.Fatalf("改名应 200,得到 %d %+v", status, env.Error)
}
got := decodeData[map[string]any](t, env)
if got["id"] != "browser-1" || got["alias"] != "二号楼平板" {
t.Fatalf("响应应是该实例的最新记录(含新别名): %+v", got)
}
// 设备字段与老字段一并返回:形状与列表元素一致。
if got["device"] != "安卓 13 · Chrome 120 · 手机" || got["deviceRaw"] != "Mozilla/5.0 (Linux; Android 13)" {
t.Fatalf("响应应带上设备字段: %+v", got)
}
for _, key := range []string{"online", "lastHeartbeat", "connectedAt", "ackVersion", "slow", "screen"} {
if _, ok := got[key]; !ok {
t.Fatalf("响应缺少老字段 %s: %+v", key, got)
}
}
// 列表里也看得到:别名是列表这一层与实例合并的。
list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances"))
if len(list) != 1 || list[0]["alias"] != "二号楼平板" {
t.Fatalf("列表应带别名: %+v", list)
}
}
func TestDisplayAliasOverwriteAndTrim(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
// 粘贴来的名字几乎都带首尾空白,服务端统一 trim 后再存。
if status, env := r.putAlias(t, client, "browser-1", " 二号楼平板 "); status != http.StatusOK {
t.Fatalf("首次改名应 200,得到 %d %+v", status, env.Error)
}
if status, env := r.putAlias(t, client, "browser-1", "三楼平板"); status != http.StatusOK {
t.Fatalf("覆盖改名应 200,得到 %d %+v", status, env.Error)
}
if got := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")); len(got) != 1 || got[0]["alias"] != "三楼平板" {
t.Fatalf("列表应只剩最新别名: %+v", got)
}
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if len(stored) != 1 || stored["browser-1"] != "三楼平板" {
t.Fatalf("覆盖后映射里应只剩最新别名: %+v", stored)
}
}
// 清除别名必须真把这条从映射里删掉(不是存空串),否则映射只会一路涨。
func TestDisplayAliasClearRemovesEntry(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true}, realtime.Instance{ID: "browser-2", Online: true})
client := r.login(t, "recorder", "recorder-pass")
if status, _ := r.putAlias(t, client, "browser-1", "二号楼平板"); status != http.StatusOK {
t.Fatalf("首次改名应 200,得到 %d", status)
}
if status, _ := r.putAlias(t, client, "browser-2", "三楼平板"); status != http.StatusOK {
t.Fatalf("另一台改名应 200,得到 %d", status)
}
// 只空白 = 清除(与前端同口径)。
status, env := r.putAlias(t, client, "browser-1", " ")
if status != http.StatusOK {
t.Fatalf("清除别名应 200,得到 %d %+v", status, env.Error)
}
got := decodeData[map[string]any](t, env)
if _, ok := got["alias"]; ok {
t.Fatalf("清除后响应里不该再有 alias 键(omitempty): %+v", got)
}
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if _, ok := stored["browser-1"]; ok {
t.Fatalf("清除后映射里不该还留着这一条: %+v", stored)
}
if stored["browser-2"] != "三楼平板" {
t.Fatalf("别的设备的别名不该被连带清掉: %+v", stored)
}
list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances"))
for _, item := range list {
if item["id"] == "browser-1" {
if _, ok := item["alias"]; ok {
t.Fatalf("清除别名后列表应回到自动标签: %+v", item)
}
}
}
}
// ---------- 落盘:换连接 + 换服务端都还在 ----------
func TestDisplayAliasSurvivesServerRestart(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{
ID: "browser-1", Online: true, Device: "安卓 13 · 手机",
})
client := r.login(t, "recorder", "recorder-pass")
if status, env := r.putAlias(t, client, "browser-1", "二号楼平板"); status != http.StatusOK {
t.Fatalf("改名应 200,得到 %d %+v", status, env.Error)
}
// 1) 换一条全新的系统库连接读(绕开任何进程内状态):别名在库里。
path := sysDBPath(t, r.env.sys)
fresh, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("重新打开系统库: %v", err)
}
defer func() { _ = fresh.Close() }()
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, fresh)), &stored); err != nil {
t.Fatalf("重开系统库后别名设置应是 JSON 映射: %v", err)
}
if stored["browser-1"] != "二号楼平板" {
t.Fatalf("重开系统库后别名应还在: %+v", stored)
}
// 2) 再起一个全新的服务端(新的 handler、新的实时层替身),同一份系统库 / 赛事库。
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
restarted := &aliasFakeRealtime{}
restarted.set(realtime.Instance{ID: "browser-1", Online: true, Device: "安卓 13 · 手机"})
srv := httptest.NewServer(httpapi.New(httpapi.Options{
Logger: logger,
Version: "test",
System: r.env.sys,
Event: r.env.ev,
Auth: r.env.auth,
DataDir: t.TempDir(),
Realtime: restarted,
}))
defer srv.Close()
restartEnv := &testEnv{t: t, server: srv, sys: r.env.sys, ev: r.env.ev, auth: r.env.auth}
restartClient, _ := restartEnv.client()
restartEnv.login(restartClient, "recorder", "recorder-pass")
list := decodeData[[]map[string]any](t, mustDo(t, restartEnv, restartClient, http.MethodGet, "/api/display/instances"))
if len(list) != 1 || list[0]["alias"] != "二号楼平板" {
t.Fatalf("重开服务端后列表里应仍有别名: %+v", list)
}
}
// ---------- 实例过期后别名留着 ----------
func TestDisplayAliasKeptAfterInstancePruned(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
if status, env := r.putAlias(t, client, "browser-1", "二号楼平板"); status != http.StatusOK {
t.Fatalf("改名应 200,得到 %d %+v", status, env.Error)
}
// 实例被 TTL 清理(注册表里没有它了):列表里当然没有条目,别名也不能跟着被删。
r.fake.set()
if list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")); len(list) != 0 {
t.Fatalf("实例被清理后列表应为空: %+v", list)
}
if status, env := r.putAlias(t, client, "browser-1", "x"); status != http.StatusNotFound {
t.Fatalf("给已不在注册表里的实例改名应 404,得到 %d %+v", status, env.Error)
}
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if stored["browser-1"] != "二号楼平板" {
t.Fatalf("实例被清理不该牵连别名: %+v", stored)
}
// 那台设备再连上来:同一个实例 id,别名自动认回来。
r.fake.set(realtime.Instance{ID: "browser-1", Online: true, Device: "安卓 13 · 手机"})
list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances"))
if len(list) != 1 || list[0]["alias"] != "二号楼平板" {
t.Fatalf("设备重连后应自动认回别名: %+v", list)
}
}
// ---------- 权限 ----------
// 权限与大屏切页同档(recorder):未登录与切页接口得到同一个状态码,录入员能改名、管理员同样能。
func TestDisplayAliasPermissionMatchesDisplayCommand(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
anon, _ := r.env.client()
// 未登录请求不带 CSRF(中间件对 guest 请求直接放行,由 RequireWrite 判权限)。
aliasStatus, aliasEnv := r.env.do(anon, http.MethodPut,
"/api/display/instances/browser-1/alias", map[string]any{"alias": "x"}, false)
cmdStatus, _ := r.env.do(anon, http.MethodPost, "/api/display/command",
map[string]any{"action": "goto", "payload": map[string]any{"page": "standings"}}, false)
if aliasStatus != cmdStatus {
t.Fatalf("改名与大屏切页应同档:匿名访客改名 %d,切页 %d", aliasStatus, cmdStatus)
}
if aliasStatus != http.StatusUnauthorized {
t.Fatalf("匿名访客应被挡下(写接口未登录一律 401),得到 %d %+v", aliasStatus, aliasEnv.Error)
}
recorder := r.login(t, "recorder", "recorder-pass")
if status, env := r.putAlias(t, recorder, "browser-1", "二号楼平板"); status != http.StatusOK {
t.Fatalf("录入员改名应 200,得到 %d %+v", status, env.Error)
}
boss := r.login(t, "boss", "boss-pass")
if status, env := r.putAlias(t, boss, "browser-1", "一号楼大屏"); status != http.StatusOK {
t.Fatalf("管理员改名应 200,得到 %d %+v", status, env.Error)
}
}
// ---------- 404 / 超长 / 超量 ----------
// 实例 id 走路径,前端会先 encodeURIComponent:服务端必须按**解码后**的 id 去认实例。
func TestDisplayAliasAcceptsURLEncodedID(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser 1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
status, env := r.env.do(client, http.MethodPut,
"/api/display/instances/browser%201/alias", map[string]any{"alias": "二号楼平板"}, true)
if status != http.StatusOK {
t.Fatalf("URL 编码的实例 id 应能认出来,得到 %d %+v", status, env.Error)
}
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if stored["browser 1"] != "二号楼平板" {
t.Fatalf("别名应按解码后的 id 存: %+v", stored)
}
}
func TestDisplayAliasUnknownInstanceNotFound(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
status, env := r.putAlias(t, client, "gone-1", "二号楼平板")
if status != http.StatusNotFound {
t.Fatalf("给不存在的实例改名应 404,得到 %d %+v", status, env.Error)
}
if env.Error == nil || !strings.Contains(env.Error.Message, "离线") {
t.Fatalf("404 提示里应说清「可能已经离线久了」: %+v", env.Error)
}
// 失败的请求不该留下任何东西。
if got := rawAliasSetting(t, r.env.sys); strings.Contains(got, "gone-1") {
t.Fatalf("404 不该写库: %s", got)
}
}
func TestDisplayAliasLengthLimit(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
// 上限按**字符**算:40 个汉字正好合法(按字节算早就超了)。
if status, env := r.putAlias(t, client, "browser-1", strings.Repeat("平", 40)); status != http.StatusOK {
t.Fatalf("40 个字符的别名应 200,得到 %d %+v", status, env.Error)
}
status, env := r.putAlias(t, client, "browser-1", strings.Repeat("平", 41))
if status != http.StatusBadRequest {
t.Fatalf("超长别名应 400,得到 %d %+v", status, env.Error)
}
if env.Error == nil || !strings.Contains(env.Error.Message, "40") {
t.Fatalf("超长提示应说清上限: %+v", env.Error)
}
// 被拒绝的请求不该改动已存的值。
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if stored["browser-1"] != strings.Repeat("平", 40) {
t.Fatalf("超长请求不该覆盖已存别名: %+v", stored)
}
// 带控制字符的别名同样要清干净再存(否则列表排版会被搞乱)。
if status, _ := r.putAlias(t, client, "browser-1", "二号楼\n平板\t"); status != http.StatusOK {
t.Fatalf("带控制字符的别名应 200,得到 %d", status)
}
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if stored["browser-1"] != "二号楼平板" {
t.Fatalf("别名里的控制字符应被清掉: %q", stored["browser-1"])
}
}
func TestDisplayAliasCapacityLimit(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "new-dev", Online: true}, realtime.Instance{ID: "dev-0", Online: true})
client := r.login(t, "recorder", "recorder-pass")
// 直接种满 200 条(逐条打接口太慢,这里要验的是上限判定本身)。
seed := make(map[string]string, 200)
for i := 0; i < 200; i++ {
seed[fmt.Sprintf("dev-%d", i)] = fmt.Sprintf("设备 %d", i)
}
raw, err := json.Marshal(seed)
if err != nil {
t.Fatalf("构造种入数据: %v", err)
}
r.env.execSys(t, `INSERT INTO settings (key, value, updated_at) VALUES ('display.instance_aliases', ?, datetime('now'))`,
string(raw))
status, env := r.putAlias(t, client, "new-dev", "二号楼平板")
if status != http.StatusConflict {
t.Fatalf("映射已满时新增应 409,得到 %d %+v", status, env.Error)
}
if env.Error == nil || !strings.Contains(env.Error.Message, "清除") {
t.Fatalf("拒绝提示应告诉操作员怎么办: %+v", env.Error)
}
// 已存在的条目仍然可以改名(覆盖不涨条数),也可以清除(腾位置)。
if status, env := r.putAlias(t, client, "dev-0", "换一个名字"); status != http.StatusOK {
t.Fatalf("已存在的条目改名应 200,得到 %d %+v", status, env.Error)
}
if status, env := r.putAlias(t, client, "dev-0", ""); status != http.StatusOK {
t.Fatalf("清除应 200,得到 %d %+v", status, env.Error)
}
if status, env := r.putAlias(t, client, "new-dev", "二号楼平板"); status != http.StatusOK {
t.Fatalf("腾出位置后新增应 200,得到 %d %+v", status, env.Error)
}
}
// 两个人同时给不同设备改名:一个都不许被另一个覆盖掉(读—改—写必须在同一个事务里)。
func TestDisplayAliasConcurrentRenamesKeepAll(t *testing.T) {
r := newAliasEnv(t)
const n = 8
instances := make([]realtime.Instance, 0, n)
for i := 0; i < n; i++ {
instances = append(instances, realtime.Instance{ID: fmt.Sprintf("browser-%d", i), Online: true})
}
r.fake.set(instances...)
// 只登录一次:登录接口有按 IP 的频控,每个 goroutine 各登一次会被 429 挡下。
// Cookie jar 自身并发安全,同一个 client 可以并发发起写请求。
client := r.login(t, "recorder", "recorder-pass")
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
status, env := r.putAlias(t, client, fmt.Sprintf("browser-%d", i), fmt.Sprintf("设备 %d", i))
if status != http.StatusOK {
t.Errorf("并发改名应 200,得到 %d %+v", status, env.Error)
}
}(i)
}
wg.Wait()
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if len(stored) != n {
t.Fatalf("并发改名后应保留全部 %d 条,得到 %d 条: %+v", n, len(stored), stored)
}
for i := 0; i < n; i++ {
if stored[fmt.Sprintf("browser-%d", i)] != fmt.Sprintf("设备 %d", i) {
t.Fatalf("browser-%d 的别名不对: %+v", i, stored)
}
}
}
// ---------- 老字段一个不少 / 老客户端不受影响 ----------
// 三个新字段都是 omitempty:老客户端(不上报设备)的列表元素**形状与升级前逐字一致**,
// 管理端不会因为多出空串键而被判成回归;有值时才出现。
func TestDisplayInstancesKeepsLegacyShape(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(
realtime.Instance{ID: "old-1", Online: true, Page: "standings", Screen: "screen-a", RemoteAddr: "10.0.0.5:1234"},
realtime.Instance{ID: "new-1", Online: true, Device: "安卓 13 · 手机", DeviceRaw: "Mozilla/5.0"},
)
client := r.login(t, "recorder", "recorder-pass")
raw := mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")
var list []map[string]any
if err := json.Unmarshal(raw.Data, &list); err != nil {
t.Fatalf("解析列表: %v", err)
}
if len(list) != 2 {
t.Fatalf("列表应有两项: %+v", list)
}
legacyKeys := []string{"id", "online", "lastHeartbeat", "connectedAt", "ackVersion", "slow", "screen"}
for _, item := range list {
for _, key := range legacyKeys {
if _, ok := item[key]; !ok {
t.Fatalf("老字段 %s 必须一个不少: %+v", key, item)
}
}
}
for _, item := range list {
if item["id"] != "old-1" {
continue
}
for _, key := range []string{"device", "deviceRaw", "alias"} {
if _, ok := item[key]; ok {
t.Fatalf("老客户端(没报设备)不该出现新键 %s: %+v", key, item)
}
}
}
for _, item := range list {
if item["id"] != "new-1" {
continue
}
if item["device"] != "安卓 13 · 手机" || item["deviceRaw"] != "Mozilla/5.0" {
t.Fatalf("报了设备字段的实例应带上它们: %+v", item)
}
if _, ok := item["alias"]; ok {
t.Fatalf("没设过别名时不该出现 alias 键: %+v", item)
}
}
}
// 设置值被改坏时(手工改库 / 别的版本写的)列表照常可用,只是退回显示实例 id。
func TestDisplayInstancesToleratesCorruptAliasSetting(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
r.env.execSys(t, `INSERT INTO settings (key, value, updated_at) VALUES ('display.instance_aliases', '不是 JSON', datetime('now'))`)
status, env := r.env.do(client, http.MethodGet, "/api/display/instances", nil, false)
if status != http.StatusOK {
t.Fatalf("坏设置不该让列表打不开,得到 %d %+v", status, env.Error)
}
list := decodeData[[]map[string]any](t, env)
if len(list) != 1 || list[0]["id"] != "browser-1" {
t.Fatalf("列表应照常返回实例: %+v", list)
}
if _, ok := list[0]["alias"]; ok {
t.Fatalf("坏设置下不该有别名: %+v", list[0])
}
}
// ---------- 端到端:真 Hub + 真 /ws + 真 HTTP ----------
// 现场原场景:局域网浏览器(URL 没有 instance,hello 不带 instanceId)连上来,
// 翻页让前端补发第二条 hello;管理端看到的应当是**一条**带设备标签的实例,且能给它改名。
//
// 这里不用替身:真的 realtime.Hub、真的 WebSocket、真的 HTTP 链路,只有浏览器那一端是
// 测试里手写的帧。
func TestDisplayAliasEndToEndWithRealHub(t *testing.T) {
var hub *realtime.Hub
env := newTestEnvWith(t, func(o *httpapi.Options) {
hub = realtime.NewHub(o.Event, o.Logger)
o.Realtime = hub
o.WS = hub.Handler()
})
t.Cleanup(func() { _ = hub.Close() })
env.createUser("recorder", "recorder-pass", "recorder")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
wsURL := "ws" + strings.TrimPrefix(env.server.URL, "http") + "/ws"
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("连接 /ws: %v", err)
}
defer func() { _ = conn.CloseNow() }()
// hello 不带 instanceId / displayKey:局域网浏览器的真实形状。
hello := func(page string) {
t.Helper()
msg, err := json.Marshal(map[string]any{
"event": "hello",
"data": map[string]any{
"role": "display",
"page": page,
"device": "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机",
"deviceRaw": "Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36",
},
})
if err != nil {
t.Fatalf("编码 hello: %v", err)
}
if err := conn.Write(ctx, websocket.MessageText, msg); err != nil {
t.Fatalf("发送 hello: %v", err)
}
}
client := func() *http.Client {
t.Helper()
c, _ := env.client()
env.login(c, "recorder", "recorder-pass")
return c
}
admin := client()
list := func() []map[string]any {
t.Helper()
return decodeData[[]map[string]any](t, mustDo(t, env, admin, http.MethodGet, "/api/display/instances"))
}
waitList := func(what string, cond func([]map[string]any) bool) []map[string]any {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var last []map[string]any
for time.Now().Before(deadline) {
last = list()
if cond(last) {
return last
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("等待「%s」超时,最后的列表: %+v", what, last)
return nil
}
hello("standings")
first := waitList("第一条 hello 被登记", func(l []map[string]any) bool {
return len(l) == 1 && l[0]["page"] == "standings"
})
id, _ := first[0]["id"].(string)
if !strings.HasPrefix(id, "display-") {
t.Fatalf("不带 instanceId 时实例 id 应由服务端生成,得到 %q", id)
}
if first[0]["device"] != "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机" {
t.Fatalf("列表应带上设备标签: %+v", first[0])
}
// 翻页:前端 setPage 会再发一条同样不带 instanceId 的 hello——这正是那个 bug 的引信。
hello("medals")
second := waitList("翻页后的 hello 被登记", func(l []map[string]any) bool {
return len(l) == 1 && l[0]["page"] == "medals"
})
if second[0]["id"] != id {
t.Fatalf("同一条连接重复 hello 不该换实例:首次 %v,翻页后 %v", id, second[0]["id"])
}
// 改名:返回该实例的最新记录,列表里也立刻带上别名。
status, env2 := env.do(admin, http.MethodPut,
"/api/display/instances/"+id+"/alias", map[string]any{"alias": "二号楼平板"}, true)
if status != http.StatusOK {
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
}
if got := decodeData[map[string]any](t, env2); got["alias"] != "二号楼平板" || got["id"] != id {
t.Fatalf("改名应返回该实例的最新记录: %+v", got)
}
after := waitList("别名出现在列表里", func(l []map[string]any) bool {
return len(l) == 1 && l[0]["alias"] == "二号楼平板"
})
if after[0]["device"] == "" {
t.Fatalf("别名不该顶掉设备标签(两者各有各的用处): %+v", after[0])
}
// 清除:回到自动设备标签,注册表里仍然只有这一条。
status, env3 := env.do(admin, http.MethodPut,
"/api/display/instances/"+id+"/alias", map[string]any{"alias": " "}, true)
if status != http.StatusOK {
t.Fatalf("清除别名应 200,得到 %d %+v", status, env3.Error)
}
if _, ok := decodeData[map[string]any](t, env3)["alias"]; ok {
t.Fatalf("清除后响应不该再有 alias: %s", env3.Data)
}
if l := list(); len(l) != 1 || l[0]["device"] == "" {
t.Fatalf("清除后应回到自动设备标签且实例仍只有一条: %+v", l)
}
}
// ---------- 小工具 ----------
// execSys 在系统库上直接执行 SQL:别名的设置在 system.db 的 settings 表里,
// 而 testEnv.exec 写的是赛事库(种数据 / 改坏设置这两个场景都要用系统库)。
func (e *testEnv) execSys(t *testing.T, query string, args ...any) {
t.Helper()
if _, err := e.sys.Exec(query, args...); err != nil {
t.Fatalf("执行系统库 SQL 失败: %v\n%s", err, query)
}
}
func mustDo(t *testing.T, env *testEnv, client *http.Client, method, path string) envelope {
t.Helper()
status, body := env.do(client, method, path, nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("%s %s 应 200,得到 %d %+v", method, path, status, body.Error)
}
return body
}
// sysDBPath 取系统库的文件路径:用它另开一条连接读,绕开进程内可能存在的任何缓存。
func sysDBPath(t *testing.T, db *sql.DB) string {
t.Helper()
rows, err := db.Query(`PRAGMA database_list`)
if err != nil {
t.Fatalf("读取系统库路径: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var seq int
var name, file string
if err := rows.Scan(&seq, &name, &file); err != nil {
t.Fatalf("扫描系统库路径: %v", err)
}
if name == "main" {
return file
}
}
t.Fatal("系统库路径为空")
return ""
}
+33 -4
View File
@@ -271,16 +271,45 @@ func (s *server) liveEventsHandler(w http.ResponseWriter, r *http.Request) (any,
return out, nil return out, nil
} }
// mainChannelRankJoin 是大屏成绩行上名次列的「主通道 + 通道名次」两段关联,
// 与 realtime 侧 snapshot.go 的同名常量逐字一致(那边是 WS 快照的最新成绩,这里是实时赛况),
// 两处必须一起改,否则分项成绩页与实时赛况的名次会打架。
//
// 名次取「主通道」口径(devdocs 17 §11.3):主通道 = scope=athlete 优先,其次通道 sort_order 小者,
// 与 export/load.go 的分项成绩表同一条规则。优先级是 **results.rank 优先、channel_ranks 兜底**
// (见下面查询里的 COALESCE(r.rank, cr.rank)),不能照抄导出侧的 cr 优先:results.rank 是这条报名项
// 自己的名次,而「只更新名次」的发布(rankOnly,scoring.go)只写 results.rank、不重建 channel_ranks
// ——cr 一优先,个人项目在这种发布之后会显示上一次的旧名次。r 为空时才回落到 cr,
// 正好补上团体主体名次:团体名次有意不写 results.rank(applyComputedRanks 只认个人主体),
// 不回落到 channel_ranks 的话,团体项目发布之后大屏上这一行就是一个「-」。
//
// 关联用 cr.entry_id = e.id,覆盖 basis=result 的名次(含纯团体项目,subject_key='e:<报名项id>')。
// 覆盖不到聚合 / 团体手工名次:那类 channel_ranks 行的 entry_id / result_id 都是 NULL,
// 主体是 t:<单位id> / a:<运动员id>,而这类主体在分项成绩表里本来就没有对应的一行
// (导出侧同一段子查询、同一处口径,见 export/load.go 的 loadEventRowsWithMembers)。
// 取 MIN(cr2.id) = 报名项自己那一份名次:团体报名挂上队员后同一通道下还有每个队员的个人副本,
// 直接按 entry_id JOIN 会把这一行印 N+1 遍(与导出侧同一处理)。
const mainChannelRankJoin = `
LEFT JOIN event_score_channels mc ON mc.id = (
SELECT c2.id FROM event_score_channels c2
WHERE c2.event_id = r.event_id
ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id
LIMIT 1)
LEFT JOIN channel_ranks cr ON cr.id = (
SELECT MIN(cr2.id) FROM channel_ranks cr2
WHERE cr2.channel_id = mc.id AND cr2.entry_id = e.id AND cr2.category_id = e.category_id)`
// liveResultGroups 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。 // liveResultGroups 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。
// //
// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序), // 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序、名次同样是
// 但范围固定在这一个小项、且不设条数上限——当前项目要显示完整名单,不能被 20 条截断。 // results.rank 优先 + channel_ranks 兜底),但范围固定在这一个小项、且不设条数上限——
// 当前项目要显示完整名单,不能被 20 条截断。
func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.EventResultGroup, error) { func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.EventResultGroup, error) {
rows, err := s.ev.QueryContext(ctx, ` rows, err := s.ev.QueryContext(ctx, `
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''),
e.category_id, COALESCE(cat.name, ''), e.category_id, COALESCE(cat.name, ''),
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''), COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''),
r.entry_id, r.rank, e.team_id, r.entry_id, COALESCE(r.rank, cr.rank), e.team_id,
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path, COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path,
e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''), e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''),
COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status
@@ -289,7 +318,7 @@ func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.Eve
JOIN events ev ON ev.id = r.event_id JOIN events ev ON ev.id = r.event_id
LEFT JOIN event_categories cat ON cat.id = e.category_id LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN teams t ON t.id = e.team_id LEFT JOIN teams t ON t.id = e.team_id
LEFT JOIN athletes a ON a.id = e.athlete_id LEFT JOIN athletes a ON a.id = e.athlete_id`+mainChannelRankJoin+`
WHERE r.event_id = ? AND r.state = 'published' AND r.status <> 'withdrawn' WHERE r.event_id = ? AND r.state = 'published' AND r.status <> 'withdrawn'
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`, eventID) ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`, eventID)
if err != nil { if err != nil {
+180 -19
View File
@@ -1,32 +1,42 @@
package httpapi_test package httpapi_test
import ( import (
"context"
"database/sql"
"net/http" "net/http"
"strconv" "strconv"
"testing" "testing"
"time" "time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
) )
type liveEventsBody struct { type liveEventsBody struct {
Source string `json:"source"` Source string `json:"source"`
EventIDs []int64 `json:"eventIds"` EventIDs []int64 `json:"eventIds"`
Events []struct { Events []liveEventBody `json:"events"`
Event struct { }
ID int64 `json:"id"`
Name string `json:"name"` type liveEventBody struct {
} `json:"event"` Event struct {
Reason string `json:"reason"` ID int64 `json:"id"`
Groups []struct { Name string `json:"name"`
EventID int64 `json:"eventId"` } `json:"event"`
CategoryID int64 `json:"categoryId"` Reason string `json:"reason"`
CategoryName string Groups []liveGroupBody `json:"groups"`
Rows []struct { }
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"` type liveGroupBody struct {
ResultText string `json:"resultText"` EventID int64 `json:"eventId"`
} `json:"rows"` CategoryID int64 `json:"categoryId"`
} `json:"groups"` CategoryName string `json:"categoryName"`
} `json:"events"` Rows []liveRowBody `json:"rows"`
}
type liveRowBody struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText string `json:"resultText"`
} }
type settingsBody struct { type settingsBody struct {
@@ -203,3 +213,154 @@ func TestDisplayLiveEmptyEvent(t *testing.T) {
t.Fatalf("未配置时默认 auto,得到 %q", got.Source) t.Fatalf("未配置时默认 auto,得到 %q", got.Source)
} }
} }
// ---------- 大屏名次:团体项目回落 channel_ranks / 个人项目仍以 results.rank 为准 ----------
// latestResultsRank 取 WS 快照 latestResults 里某条报名项的名次——大屏的分项成绩页与底栏读的就是它。
func latestResultsRank(t *testing.T, env *testEnv, entryID int64) *int {
t.Helper()
hub := realtime.NewHub(env.ev, nil)
t.Cleanup(func() { _ = hub.Close() })
snap, err := hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
for _, group := range snap.LatestResults {
for _, row := range group.Rows {
if row.EntryID == entryID {
return row.Rank
}
}
}
t.Fatalf("快照最新成绩里没有报名项 %d 的行:%+v", entryID, snap.LatestResults)
return nil
}
// liveRanksForEvent 取实时赛况(GET /api/display/live)里某小项各报名项的名次。
// 该小项没被选中时直接失败——这类用例验证的正是它必须出现在大屏上。
func liveRanksForEvent(t *testing.T, env *testEnv, eventID int64) map[int64]*int {
t.Helper()
guest, _ := env.client()
status, resp := env.do(guest, http.MethodGet, "/api/display/live", nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读取实时赛况应 200,得到 %d %+v", status, resp.Error)
}
body := decodeData[liveEventsBody](t, resp)
for _, ev := range body.Events {
if ev.Event.ID != eventID {
continue
}
out := make(map[int64]*int)
for _, group := range ev.Groups {
for _, row := range group.Rows {
out[row.EntryID] = row.Rank
}
}
return out
}
t.Fatalf("实时赛况里没有小项 %d(已选中 %+v)", eventID, body.EventIDs)
return nil
}
// fmtRank 把可空名次转成可读文本:为空就是现场那个「-」。
func fmtRank(rank *int) string {
if rank == nil {
return "空(大屏上就是「-」)"
}
return strconv.Itoa(*rank)
}
// 现场回归:纯团体小项(event_type=team、通道 scope=team + basis=result,即「按成绩自动排名」的
// 团体 4x100 接力)发布之后,大屏上显示该项目名次的地方都必须给出真实名次,不能是「-」。
//
// 团体名次只落 channel_ranks、results.rank 有意留空(applyComputedRanks 只认个人主体,工单 A-1/A-2),
// 所以大屏两个读者都得回落到 channel_ranks:WS 快照的 latestResults(分项成绩页 + 底栏)
// 与实时赛况的 liveResultGroups(/api/display/live)。
func TestDisplayShowsTeamRankForTeamEvent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID, firstEntry := seedTeamEvent(t, env, ids)
// seedTeamEvent 只建了一个单位的报名项,再补一个单位,名次才排得出来。
secondEntry := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamB)
publishResults(t, env, client, eventID, "team-rank",
map[string]any{"entryId": firstEntry, "resultValue": 45.20, "resultText": "45.20", "unit": "s", "status": "ok"},
map[string]any{"entryId": secondEntry, "resultValue": 46.10, "resultText": "46.10", "unit": "s", "status": "ok"},
)
// 1) 写侧不变:团体名次仍然不写 results.rank。
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, eventID); n != 0 {
t.Fatalf("团体名次不该写回 results.rank,得到 %d 行", n)
}
// 2) 名次落在 channel_ranks 上,且 entry_id / result_id 都有值(下钻与导出靠它关联)。
var (
rank int
resultID sql.NullInt64
)
if err := env.ev.QueryRow(`SELECT rank, result_id FROM channel_ranks
WHERE event_id = ? AND category_id = ? AND scope = 'team' AND basis = 'result'
AND entry_id = ?`, eventID, categoryID, firstEntry).Scan(&rank, &resultID); err != nil {
t.Fatalf("读团体名次: %v", err)
}
if rank != 1 || !resultID.Valid {
t.Fatalf("channel_ranks 应有一行 rank=1 且挂 result_id,得到 rank=%d resultID=%+v", rank, resultID)
}
// 3) / 4) 大屏两个读者都带上名次。
for entryID, want := range map[int64]int{firstEntry: 1, secondEntry: 2} {
if got := latestResultsRank(t, env, entryID); got == nil || *got != want {
t.Fatalf("快照(分项成绩页 + 底栏)里报名项 %d 的名次 = %s,期望 %d", entryID, fmtRank(got), want)
}
}
live := liveRanksForEvent(t, env, eventID)
for entryID, want := range map[int64]int{firstEntry: 1, secondEntry: 2} {
if got := live[entryID]; got == nil || *got != want {
t.Fatalf("实时赛况里报名项 %d 的名次 = %s,期望 %d(完整:%+v)", entryID, fmtRank(got), want, live)
}
}
}
// 不回归:个人项目的名次以 results.rank 为准,channel_ranks 只是空值兜底。
//
// 「只更新名次」的发布(rankOnly)只写 results.rank、不重建 channel_ranks:这里先把决赛计分发布,
// 再改成绩按「只更新名次」发布——results.rank 换成新名次,channel_ranks 停在决赛那一版。
// 大屏若改成 channel_ranks 优先,这种发布之后就会显示上一次的旧名次。
func TestDisplayPrefersResultRankAfterRankOnlyPublish(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "rank-pref-final",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
assertRanksBothReaders(t, env, ids.event, map[int64]int{ids.entryA: 1, ids.entryB: 2})
// 改成绩(现场是退回草稿改完再发布),按「只更新名次」发布:名次换位。
env.exec(t, `UPDATE results SET result_value = 11.92, result_text = '11.92' WHERE entry_id = ?`, ids.entryA)
env.exec(t, `UPDATE results SET result_value = 11.58, result_text = '11.58' WHERE entry_id = ?`, ids.entryB)
publishWith(t, env, client, ids.event, "rank-pref-rankonly", rankOnlyTrue)
// 前提:results.rank 是新名次,channel_ranks 还是旧名次(这种发布不重建它)。
if got := resultRank(t, env, ids.entryA); got != 2 {
t.Fatalf("只更新名次的发布应把 A 写成第 2 名,得到 %d", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE event_id = ? AND entry_id = ? AND rank = 1`,
ids.event, ids.entryA); n != 1 {
t.Fatalf("前提不成立:channel_ranks 应停在旧名次(A 第 1 名),得到 %d 行", n)
}
// 大屏必须跟着 results.rank 走,不能退回 channel_ranks 的旧名次。
assertRanksBothReaders(t, env, ids.event, map[int64]int{ids.entryA: 2, ids.entryB: 1})
}
// assertRanksBothReaders 断言一组报名项在大屏两个读者上的名次(快照 + 实时赛况)。
func assertRanksBothReaders(t *testing.T, env *testEnv, eventID int64, want map[int64]int) {
t.Helper()
live := liveRanksForEvent(t, env, eventID)
for entryID, rank := range want {
if got := latestResultsRank(t, env, entryID); got == nil || *got != rank {
t.Fatalf("快照(分项成绩页 + 底栏)里报名项 %d 的名次 = %s,期望 %d", entryID, fmtRank(got), rank)
}
if got := live[entryID]; got == nil || *got != rank {
t.Fatalf("实时赛况里报名项 %d 的名次 = %s,期望 %d(完整:%+v)", entryID, fmtRank(got), rank, live)
}
}
}
+192 -12
View File
@@ -3,8 +3,13 @@ package httpapi
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt"
"log/slog"
"net/http" "net/http"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
) )
@@ -16,6 +21,193 @@ type Realtime interface {
Instances() []realtime.Instance Instances() []realtime.Instance
} }
// ---------- 大屏别名(机器级设置) ----------
//
// 别名与实例注册表是两套生命周期:
// - 实例注册表在**内存**里,连接断开后只留 TTL(十分钟)供排查;
// - 别名是操作员给「这台设备」起的名字,必须活过重启、活过实例过期。
//
// 所以别名存系统库 system.db 的 settings 表(与 PUT /api/system/session 的登录有效期同一套
// 读写机制,见 repo_misc.go 的 writeSessionHours),不新建表、不写迁移。
const (
// instanceAliasKey 下存一个 JSON 映射:{"<instanceId>": "<别名>"}。
instanceAliasKey = "display.instance_aliases"
// maxInstanceAliases 是映射条数上限:别名会在实例过期后继续留着(下次那台设备连上来
// 还认得出),只增不减会一路涨下去,必须有封顶。
maxInstanceAliases = 200
// maxAliasChars 是单个别名的字符数上限(按字符不是字节,中文名才存得下)。
maxAliasChars = 40
)
// displayAliasPatch 是 PUT /api/display/instances/{id}/alias 的请求体。
type displayAliasPatch struct {
// Alias 为空串或只空白 = 清除别名、回到自动设备标签。
Alias string `json:"alias"`
RequestID string `json:"requestId"`
}
// displayInstancesHandler 返回在线大屏实例列表(在线看板)。
func (s *server) displayInstancesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
if s.realtime == nil {
return []realtime.Instance{}, nil
}
list := s.realtime.Instances()
if list == nil {
list = []realtime.Instance{}
}
// 别名在这里与实例合并:注册表里只剩在线的与刚离线的,而别名可能属于一台早就
// 被清理掉的设备——那种别名留着不填(没有对应实例),等它连上来自然就填上了。
aliases, err := s.loadInstanceAliases(r.Context())
if err != nil {
// 读别名失败不该让「在线大屏」列表整个打不开:现场靠它切页、排查。
// 降级成「没有别名」(列表退回显示实例 id)并留下警告,其余字段照常返回。
s.logger.Warn("读取大屏别名失败,列表按无别名返回", "err", err)
return list, nil
}
for i := range list {
if alias := aliases[list[i].ID]; alias != "" {
list[i].Alias = alias
}
}
return list, nil
}
// updateDisplayInstanceAliasHandler 处理 PUT /api/display/instances/{id}/alias:
// 给一台设备设 / 清别名,返回该实例的最新记录(形状与列表元素一致,即 Instance DTO)。
// 权限与大屏切页同档(recorder 及以上),见 server.go 的路由注册。
func (s *server) updateDisplayInstanceAliasHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id := strings.TrimSpace(r.PathValue("id"))
var in displayAliasPatch
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
alias := realtime.CleanUserText(in.Alias)
if utf8.RuneCountInString(alias) > maxAliasChars {
return nil, badRequest(
fmt.Sprintf("别名最长 %d 个字符(当前 %d 个)", maxAliasChars, utf8.RuneCountInString(alias)),
nil)
}
inst, ok := s.findDisplayInstance(id)
if !ok {
return nil, notFound("这台大屏当前不在注册表里:它可能已经离线很久、超过了保留时间。请刷新在线列表后重试。")
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
if err := s.saveInstanceAlias(r.Context(), id, alias); err != nil {
return nil, err
}
s.audit(r.Context(), "display.instance_alias", "display", 0, nil,
map[string]any{"instanceId": id, "alias": alias})
inst.Alias = alias
return inst, nil
})
}
// findDisplayInstance 在注册表快照里按 id 找实例;找不到说明它已被清理(或压根没连过)。
func (s *server) findDisplayInstance(id string) (realtime.Instance, bool) {
if id == "" || s.realtime == nil {
return realtime.Instance{}, false
}
for _, inst := range s.realtime.Instances() {
if inst.ID == id {
return inst, true
}
}
return realtime.Instance{}, false
}
// loadInstanceAliases 读「实例 id → 别名」映射。
//
// 存量值可能被手工改过、或是别的版本写的,所以读出来还要过一遍清洗:空 id / 空别名等于
// 「没设」,直接丢掉(避免死条目一直占着条数上限),别名本身按同一个口径去控制字符与截断。
func (s *server) loadInstanceAliases(ctx context.Context) (map[string]string, error) {
return loadInstanceAliasesQ(ctx, s.sys, s.logger)
}
// loadInstanceAliasesQ 与 loadInstanceAliases 相同,但可以在给定连接 / 事务上读:
// saveInstanceAlias 要在同一个事务里「先读后写」,否则两个人同时改不同设备时,
// 后写的那个会把前一个的改动覆盖掉(两边各自读到旧映射,谁也看不见谁)。
func loadInstanceAliasesQ(ctx context.Context, q queryer, logger *slog.Logger) (map[string]string, error) {
var raw string
err := q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, instanceAliasKey).Scan(&raw)
switch {
case errors.Is(err, sql.ErrNoRows):
return map[string]string{}, nil
case err != nil:
return nil, internal(err)
}
if strings.TrimSpace(raw) == "" {
return map[string]string{}, nil
}
var stored map[string]string
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
// 值被改坏时按「没有别名」处理并留下警告:一条脏设置不该把「在线大屏」列表打不开。
logger.Warn("大屏别名设置不是合法 JSON,按无别名处理", "key", instanceAliasKey, "err", err)
return map[string]string{}, nil
}
out := make(map[string]string, len(stored))
for id, alias := range stored {
id = strings.TrimSpace(id)
alias = realtime.TruncateChars(realtime.CleanUserText(alias), maxAliasChars)
if id == "" || alias == "" {
continue
}
out[id] = alias
}
return out, nil
}
// saveInstanceAlias 写入 / 清除一个实例的别名。
//
// 清除是真的把那条**删掉**(不是存空串):实例过期后别名会一直留着,只增不减的映射会一路
// 顶到 maxInstanceAliases,之后所有设备都改不了名。
//
// 超过条数上限时**拒绝**而不是淘汰最旧的:别名是按 id 存的、没有时间戳,淘汰谁都是猜;
// 而悄悄弄丢一台设备的命名比让操作员先清几条更难查。拒绝会带中文提示(409)。
func (s *server) saveInstanceAlias(ctx context.Context, id, alias string) error {
// 读—改—写必须在一个事务里(系统库只有一条连接,事务把并发改名串起来)。
tx, err := s.sys.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
aliases, err := loadInstanceAliasesQ(ctx, tx, s.logger)
if err != nil {
return err
}
if alias == "" {
if _, ok := aliases[id]; !ok {
return nil // 本来就没有别名:不必写库
}
delete(aliases, id)
} else {
if _, exists := aliases[id]; !exists && len(aliases) >= maxInstanceAliases {
s.logger.Warn("大屏别名数量已达上限,拒绝新增",
"max", maxInstanceAliases, "instanceId", id)
return conflict(fmt.Sprintf(
"大屏别名最多 %d 条,已经存满。请先给不再使用的设备清除别名后重试。", maxInstanceAliases))
}
aliases[id] = alias
}
raw, err := json.Marshal(aliases)
if err != nil {
return internal(err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
instanceAliasKey, string(raw)); err != nil {
return internal(err)
}
if err := tx.Commit(); err != nil {
return internal(err)
}
return nil
}
// broadcastChanges 在写请求成功返回后,把本次请求写入 change_log 的变更推给实时层。 // broadcastChanges 在写请求成功返回后,把本次请求写入 change_log 的变更推给实时层。
// 版本号取请求开始时的最大 id,因此只推送本次写入,不会重复补发历史变更。 // 版本号取请求开始时的最大 id,因此只推送本次写入,不会重复补发历史变更。
func (s *server) broadcastChanges(next http.Handler) http.Handler { func (s *server) broadcastChanges(next http.Handler) http.Handler {
@@ -121,15 +313,3 @@ func (w *statusRecorder) Write(b []byte) (int, error) {
} }
return w.ResponseWriter.Write(b) return w.ResponseWriter.Write(b)
} }
// displayInstancesHandler 返回在线大屏实例列表(在线看板)。
func (s *server) displayInstancesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
if s.realtime == nil {
return []realtime.Instance{}, nil
}
list := s.realtime.Instances()
if list == nil {
list = []realtime.Instance{}
}
return list, nil
}
+4
View File
@@ -324,6 +324,10 @@ func (s *server) routes(mux *http.ServeMux) {
// 大屏控制与运维 // 大屏控制与运维
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler)) mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler)) mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler))
// 给某台大屏设 / 清手动别名。**与切页同档(recorder)**:它改的是这台设备的显示名,
// 与「谁能在控制页下发指令」是同一批人,不该更严。别名是机器级设置(系统库),
// 实例注册表只在内存里,两者的合并见 displayInstancesHandler。
mux.HandleFunc("PUT /api/display/instances/{id}/alias", s.write(auth.RoleRecorder, s.updateDisplayInstanceAliasHandler))
// 「实时赛况」页的当前项目与已发布成绩;大屏(guest)要能直接拉,故对外只读开放。 // 「实时赛况」页的当前项目与已发布成绩;大屏(guest)要能直接拉,故对外只读开放。
mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler)) mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler))
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler)) mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
+6 -1
View File
@@ -511,7 +511,12 @@ func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
if id == "" { if id == "" {
id = p.DisplayKey id = p.DisplayKey
} }
h.registerDisplay(c, id, p.Page, p.Screen) h.registerDisplay(c, id, displayRegistration{
page: p.Page,
screen: p.Screen,
device: p.Device,
deviceRaw: p.DeviceRaw,
})
h.join(c, RoomDisplay) h.join(c, RoomDisplay)
} }
+51 -10
View File
@@ -30,6 +30,14 @@ type Instance struct {
// 管理端靠它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时为空串。 // 管理端靠它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时为空串。
// 用字符串而不是数字:显示器的 id 由 Electron 给,不保证是小的正整数。 // 用字符串而不是数字:显示器的 id 由 Electron 给,不保证是小的正整数。
Screen string `json:"screen"` Screen string `json:"screen"`
// Device 是浏览器报上来的设备标签(`安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`),
// DeviceRaw 是原始 UA。老客户端不报时**整个键不出现**(omitempty),管理端列表退回显示 id。
Device string `json:"device,omitempty"`
DeviceRaw string `json:"deviceRaw,omitempty"`
// Alias 是操作员手动起的名字。它**不住在注册表里**:注册表是内存的、实例过期即清理,
// 而别名是机器级的、必须活过重启与实例过期,所以由 httpapi 从系统库设置里读出来,
// 在返回列表时按实例 id 填进来(见 httpapi 的 loadInstanceAliases)。
Alias string `json:"alias,omitempty"`
} }
// instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。 // instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。
@@ -37,6 +45,8 @@ type instanceState struct {
id string id string
page string page string
screen string screen string
device string
deviceRaw string
connectedAt time.Time connectedAt time.Time
lastHeartbeat time.Time lastHeartbeat time.Time
remoteAddr string remoteAddr string
@@ -45,24 +55,45 @@ type instanceState struct {
conns int conns int
} }
// registerDisplay 把连接挂到大屏实例上;未带 instanceId 时由服务端生成, // displayRegistration 是登记一个实例要用的 hello 字段。
// 保证「刷新页面 = 同一实例」之外的连接也能被登记和查询。 // 单独一个结构体而不是直接传 helloPayload:注册表只关心「在哪一页、哪块屏、什么设备」,
func (h *Hub) registerDisplay(c *client, id, page, screen string) string { // role / lastVersion 那些与它无关。
type displayRegistration struct {
page string
screen string
device string
deviceRaw string
}
// registerDisplay 把连接挂到大屏实例上。
//
// id 为空(局域网浏览器直接开 /display,URL 上没有 instance / screen)时**优先复用这条连接
// 已经在用的实例 id**,只有从没用过才现生成一个:客户端翻页(setPage)与补齐设备标签都会
// 再发一次 hello,而每次 hello 都现生成 `display-<随机>` 的话,老实例会永远留在注册表里
// 变成一条「离线条目」(TTL 十分钟才清理),管理端「在线大屏」底下就凭空多出一条同一台
// 设备的离线记录。刷新页面等新连接仍然各自登记,不会因此查不到。
func (h *Hub) registerDisplay(c *client, id string, reg displayRegistration) string {
c.mu.Lock()
if id == "" {
id = c.instanceID
}
if id == "" { if id == "" {
id = "display-" + randomID() id = "display-" + randomID()
} }
now := time.Now()
c.mu.Lock()
prev := c.instanceID prev := c.instanceID
c.instanceID = id c.instanceID = id
c.mu.Unlock() c.mu.Unlock()
// 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。 // 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。
// 复用同一条 id 时 prev == id,引用计数不动——这正是「重复 hello 不许产生新实例」的一半。
if prev != "" && prev != id { if prev != "" && prev != id {
h.releaseDisplayID(prev, c) h.releaseDisplayID(prev, c)
} }
device := sanitizeDeviceText(reg.device, maxDeviceChars)
deviceRaw := sanitizeDeviceText(reg.deviceRaw, maxDeviceRawChars)
now := time.Now()
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
h.pruneInstancesLocked(now) h.pruneInstancesLocked(now)
@@ -74,11 +105,19 @@ func (h *Hub) registerDisplay(c *client, id, page, screen string) string {
if prev != id { if prev != id {
st.conns++ st.conns++
} }
if page != "" { if reg.page != "" {
st.page = page st.page = reg.page
} }
if screen != "" { if reg.screen != "" {
st.screen = screen st.screen = reg.screen
}
// 设备标签只在这次报了非空值时覆盖:老客户端(以及 localStorage 不可用的客户端)
// 不带这两个字段,不该把上一次 hello 报上来的标签抹掉。
if device != "" {
st.device = device
}
if deviceRaw != "" {
st.deviceRaw = deviceRaw
} }
st.lastHeartbeat = now st.lastHeartbeat = now
st.remoteAddr = c.remoteAddr st.remoteAddr = c.remoteAddr
@@ -234,6 +273,8 @@ func (h *Hub) Instances() []Instance {
AckVersion: st.ackVersion, AckVersion: st.ackVersion,
Slow: st.slow, Slow: st.slow,
Screen: st.screen, Screen: st.screen,
Device: st.device,
DeviceRaw: st.deviceRaw,
}) })
} }
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+173
View File
@@ -0,0 +1,173 @@
package realtime
import (
"strings"
"testing"
)
// ---------- 设备字段的清洗与截断 ----------
func TestSanitizeDeviceText(t *testing.T) {
longUA := strings.Repeat("汉", 400)
cases := []struct {
name string
in string
limit int
want string
}{
{"原样保留可读标签", "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机", maxDeviceChars,
"安卓 13 · Chrome 120 · 1080×2400 @3x · 手机"},
// 控制字符是能把管理端列表排版搞乱的东西(换行 / 制表 / 退格 / DEL),一律清掉。
{"换行与制表被清掉", " 安卓 13\n\tChrome 120 \r\n", maxDeviceChars, "安卓 13Chrome 120"},
{"DEL 与 C0 控制字符被清掉", "安卓\x00\x1b[31m 13\x7f", maxDeviceChars, "安卓[31m 13"},
{"首尾空白去掉", " 平板 ", maxDeviceChars, "平板"},
{"只空白视作没报", " \t\n ", maxDeviceChars, ""},
{"空串保持空串", "", maxDeviceChars, ""},
// 长度按**字符**不是字节:按字节截会把中文腰斩成半个字。
{"按字符截断(中文)", strings.Repeat("汉", 81), maxDeviceChars, strings.Repeat("汉", 80)},
{"恰好等于上限不截断", strings.Repeat("x", maxDeviceChars), maxDeviceChars, strings.Repeat("x", maxDeviceChars)},
{"超长 UA 截到 300 字符", longUA, maxDeviceRawChars, strings.Repeat("汉", maxDeviceRawChars)},
{"limit <= 0 不截断", strings.Repeat("x", 10), 0, strings.Repeat("x", 10)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeDeviceText(tc.in, tc.limit)
if got != tc.want {
t.Fatalf("清洗结果不符:\n得到 %q\n期望 %q", got, tc.want)
}
})
}
}
// 截断结果必须是合法 UTF-8(半个汉字会显示成替换方块)。
func TestSanitizeDeviceTextKeepsValidUTF8(t *testing.T) {
got := sanitizeDeviceText(strings.Repeat("平板", 100), 5)
if got != "平板平板平" {
t.Fatalf("按字符截断结果应为 %q,得到 %q", "平板平板平", got)
}
for _, r := range got {
if r == '\uFFFD' {
t.Fatalf("截断后出现无效 UTF-8 字节: %q", got)
}
}
}
// ---------- 同一条连接重复 hello 不许产生新实例 ----------
// 局域网浏览器(hello 不带 instanceId)翻页后 setPage 会再补一条 hello,
// 第二次 hello 必须落在**同一个**实例上,否则注册表里会多出一条该设备的离线条目。
func TestRegisterDisplayReusesInstanceForRepeatHelloWithoutID(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(0)
c := env.dial(t, &helloPayload{Role: RoleDisplay, Page: "standings"})
c.recvEvent(EventSnapshot)
first := env.hub.Instances()
if len(first) != 1 {
t.Fatalf("首次 hello 后应只有一个实例,得到 %d 个: %+v", len(first), first)
}
if !strings.HasPrefix(first[0].ID, "display-") {
t.Fatalf("不带 instanceId 时实例 id 应由服务端生成,得到 %s", first[0].ID)
}
// 第二次仍不带 instanceId(真实场景:翻页补发的 hello,端上没有本机 id)。
c.send(EventHello, helloPayload{Role: RoleDisplay, Page: "medals"})
c.recvEvent(EventSnapshot)
got := env.hub.Instances()
if len(got) != 1 {
t.Fatalf("重复 hello 后注册表里应仍只有一个实例(旧实现会多出一条离线条目),得到 %d 个: %+v",
len(got), got)
}
if got[0].ID != first[0].ID {
t.Fatalf("重复 hello 应复用同一个实例 id:首次 %s,第二次 %s", first[0].ID, got[0].ID)
}
if !got[0].Online {
t.Fatalf("重复 hello 后实例应仍在线: %+v", got[0])
}
if got[0].Page != "medals" {
t.Fatalf("重复 hello 应刷新当前页为 medals,得到 %s", got[0].Page)
}
// 引用计数不能虚高:连接断开后实例必须立刻变成离线(虚高会显示成「还挂着一条连接」)。
c.close()
waitFor(t, "重复 hello 后实例仍能正确离线", func() bool {
insts := env.hub.Instances()
return len(insts) == 1 && !insts[0].Online
})
}
// 换了 instanceId 的连接仍然各自登记:复用只针对「这次没带 id」,不能把显式换 id 吃掉。
func TestRegisterDisplaySwitchesInstanceWhenIDChanges(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(0)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1"})
c.recvEvent(EventSnapshot)
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "screen-2"})
c.recvEvent(EventSnapshot)
insts := env.hub.Instances()
if len(insts) != 2 {
t.Fatalf("换 id 后应有两个实例,得到 %d 个: %+v", len(insts), insts)
}
if !instanceOnline(insts, "screen-2") {
t.Fatalf("新 id 应在线: %+v", insts)
}
// 旧实例的连接引用已归还,只剩「离线」的排查记录。
if instanceOnline(insts, "screen-1") {
t.Fatalf("旧 id 的连接引用应已归还,不该仍显示在线: %+v", insts)
}
}
// ---------- 设备字段进注册表 ----------
func TestRegisterDisplayStoresSanitizedDevice(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(0)
c := env.dial(t, &helloPayload{
Role: RoleDisplay,
InstanceID: "pc-1",
Device: "安卓 13\n · Chrome 120\t",
DeviceRaw: "Mozilla/5.0 (Linux; Android 13)" + strings.Repeat("x", 400),
})
c.recvEvent(EventSnapshot)
insts := env.hub.Instances()
if len(insts) != 1 {
t.Fatalf("实例数应为 1,得到 %d", len(insts))
}
if insts[0].Device != "安卓 13 · Chrome 120" {
t.Fatalf("设备标签应清洗掉换行与制表,得到 %q", insts[0].Device)
}
if n := len([]rune(insts[0].DeviceRaw)); n != maxDeviceRawChars {
t.Fatalf("原始 UA 应按字符截到 %d,得到 %d 个字符", maxDeviceRawChars, n)
}
if strings.ContainsAny(insts[0].DeviceRaw, "\n\t") {
t.Fatalf("原始 UA 不该含控制字符: %q", insts[0].DeviceRaw)
}
// 老客户端(不带这两个字段)重连:不该把已经报上来的标签抹掉。
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "pc-1"})
c.recvEvent(EventSnapshot)
if got := env.hub.Instances()[0]; got.Device == "" || got.DeviceRaw == "" {
t.Fatalf("不带设备字段的重复 hello 不该清空已有标签: %+v", got)
}
}
// 老客户端(不带 device / deviceRaw)行为与今天一致:字段留空,注册表照常登记。
func TestRegisterDisplayWithoutDeviceFields(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "old-1"})
c.recvEvent(EventSnapshot)
insts := env.hub.Instances()
if len(insts) != 1 || insts[0].ID != "old-1" || !insts[0].Online {
t.Fatalf("老客户端应照常登记: %+v", insts)
}
if insts[0].Device != "" || insts[0].DeviceRaw != "" {
t.Fatalf("老客户端没报设备字段时应留空: %+v", insts[0])
}
}
+60
View File
@@ -7,7 +7,9 @@ package realtime
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"unicode"
) )
// 事件名与协议第 3 节的表格一一对应。 // 事件名与协议第 3 节的表格一一对应。
@@ -107,6 +109,64 @@ type helloPayload struct {
Page string `json:"page,omitempty"` Page string `json:"page,omitempty"`
// Screen 是 Console 从 URL 带下来的屏号(screen=<display.id>),局域网浏览器留空。 // Screen 是 Console 从 URL 带下来的屏号(screen=<display.id>),局域网浏览器留空。
Screen string `json:"screen,omitempty"` Screen string `json:"screen,omitempty"`
// Device 是浏览器自己拼好的设备标签(`安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`),
// DeviceRaw 是原始 UA(排障用)。两者都 omitempty:**老客户端不带这两个字段,服务端行为
// 与今天逐字一致**(管理端列表退回显示实例 id)。
//
// 服务端不解析 UA,但也不能全信这两个值:它们是客户端自报的自由文本,会直接进管理端列表。
// 长度按**字符**截断、控制字符一律清掉,收口在 sanitizeDeviceText(registerDisplay 调用)。
Device string `json:"device,omitempty"`
DeviceRaw string `json:"deviceRaw,omitempty"`
}
// 客户端自报文本的长度上限,按**字符**(rune)不是字节算:
// `安卓 13 · Chrome 120` 这种标签按字节截会切出半个汉字,显示成乱码方框。
const (
maxDeviceChars = 80
maxDeviceRawChars = 300
)
// CleanUserText 清洗客户端自报的自由文本:去掉全部控制字符(换行、制表、退格、DEL…)与首尾空白。
//
// 这些值最终要显示在管理端「在线大屏」列表里,一个带换行的 device 能把整行排版顶歪;
// 服务端是唯一的收口处,不能指望每个客户端都干净。TrimSpace 之后为空即视作「没报」,返回空串。
//
// 保留字符间的普通空格与各种可见符号:设备标签本来就长这样,不该被服务端改写。
func CleanUserText(s string) string {
if s == "" {
return ""
}
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if unicode.IsControl(r) {
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
// TruncateChars 按字符(rune)截断到 maxChars 个;maxChars <= 0 表示不截断。
// 会切断代理对时以整字符为界(Go 的 range 只落在字符起始字节上),不会切出半个 UTF-8 序列。
func TruncateChars(s string, maxChars int) string {
if maxChars <= 0 {
return s
}
count := 0
for i := range s {
if count == maxChars {
return s[:i]
}
count++
}
return s
}
// sanitizeDeviceText 是 hello 里设备字段的收口:先清洗再截断。
// 没有这两个字段的老客户端拿到的是空串,注册表按「没报」处理。
func sanitizeDeviceText(s string, maxChars int) string {
return TruncateChars(CleanUserText(s), maxChars)
} }
// ackPayload 是 C→S 的 ack 载荷;服务端据此识别慢客户端。 // ackPayload 是 C→S 的 ack 载荷;服务端据此识别慢客户端。
+112 -21
View File
@@ -14,9 +14,28 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto" "git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
) )
// latestResultsLimit 控制快照里「最新成绩」的行数上限,避免大屏首包过大。 // 快照「最新成绩」的窗口口径:先取最近发布的 K 组,再每组保底取前 R 行。
// 行会按「小项 + 组别」聚合后再下发,因此组数只会比这个值更少。 //
const latestResultsLimit = 20 // 不能按「总行数」截断:行是按发布时间倒序取的,而组是按「首次出现」建号的,
// 于是组数组的顺序 = 发布时间倒序——**屏上最旧的那一栏站在窗口尾部**,行数截断的切口
// 只会落进那一组里,把「建号最早的行」切掉。手录名次的项目通常从第 1 名往下录,
// 第 1 名的 results.id 最小 → 现场表现就是「最旧那一栏看不到第 1 名、从第 2 名开始」,
// 且症状跟着**栏位**走(同一批项目挪到前面几栏就正常)。
//
// 最坏情况下快照下发 8 × 10 = 80 行(旧口径是全局 20 行),首包因此变大;
// 换来的是每一组都从第 1 名开始,不再有「最旧一栏少一行」。
const (
// latestResultsGroupLimit 保留的**组数**上限。一组 = 一个小项 + 一个组别,即大屏的一栏。
// 前端单屏最多 4 栏(web/src/display/useScoreboard.ts 把 resultsCount 夹在 1..4),
// 底栏只需最新一组;留一倍余量给「刚发布、还没轮到显示」的组,取 8。
latestResultsGroupLimit = 8
// latestResultsRowsPerGroup 每组保留的**行数**上限,按名次取前 R 行。
// 前端每栏只画 6 行(web/src/display/results.ts 的 RESULT_ROWS_PER_COLUMN),
// 并列名次会让一栏超过 6 行;实时赛况页的同一批数据上限是 10 行
// (web/src/display/live.ts 的 MAX_LIVE_ROWS),兜底取 10。
latestResultsRowsPerGroup = 10
)
// Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。 // Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。
// 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构, // 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构,
@@ -423,15 +442,82 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
return rows.Err() return rows.Err()
} }
// mainChannelRankJoin 是大屏成绩行上名次列的「主通道 + 通道名次」两段关联,供 loadLatestResults
// 与 httpapi 的 liveResultGroups 使用(两边必须逐字相同,否则分项成绩页与实时赛况的名次会打架)。
//
// 名次取「主通道」口径(devdocs 17 §11.3):一行成绩只能出一个名次,主通道 = scope=athlete 优先,
// 其次通道 sort_order 小者,与 export/load.go 的分项成绩表同一条规则。优先级是
// **results.rank 优先、channel_ranks 兜底**(见下面各查询里的 COALESCE(r.rank, cr.rank)),
// 不能照抄导出侧的 cr 优先:results.rank 是这条报名项自己的名次,而「只更新名次」的发布
// (rankOnly,httpapi/scoring.go)只写 results.rank、不重建 channel_ranks——cr 一优先,
// 个人项目在这种发布之后会显示上一次的旧名次。反过来 r 为空时才回落到 cr,
// 正好补上团体主体名次:团体名次有意不写 results.rank(applyComputedRanks 只认个人主体)。
//
// 关联用 cr.entry_id = e.id,覆盖 basis=result 的名次(含纯团体项目,subject_key='e:<报名项id>')。
// 覆盖不到聚合 / 团体手工名次:那类 channel_ranks 行的 entry_id / result_id 都是 NULL,
// 主体是 t:<单位id> / a:<运动员id>,而这类主体在分项成绩表里本来就没有对应的一行
// (导出侧同一段子查询、同一处口径,见 export/load.go 的 loadEventRowsWithMembers)。
// 取 MIN(cr2.id) = 报名项自己那一份名次:团体报名挂上队员后同一通道下还有每个队员的个人副本,
// 直接按 entry_id JOIN 会把这一行印 N+1 遍(与导出侧同一处理)。
//
// ⚠️ 与 export/load.go(loadEventRowsWithMembers 的 cr 子查询)、httpapi/live_events.go(liveResultGroups)
// 同源,改一处要改三处。
const mainChannelRankJoin = `
LEFT JOIN event_score_channels mc ON mc.id = (
SELECT c2.id FROM event_score_channels c2
WHERE c2.event_id = r.event_id
ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id
LIMIT 1)
LEFT JOIN channel_ranks cr ON cr.id = (
SELECT MIN(cr2.id) FROM channel_ranks cr2
WHERE cr2.channel_id = mc.id AND cr2.entry_id = e.id AND cr2.category_id = e.category_id)`
// latestResultsScopedSelect 是窗口函数的输入:已发布、未作废的成绩行,每行只带分组键、
// 发布时间与名次。时间表达式与主查询逐字相同——这里少一个兜底列,组的发布时间就会漂到别的列上。
// 名次口径也必须与主查询一致(results.rank 优先、channel_ranks 兜底),
// 否则「每组取前 R 行」和「组内展示顺序」会各按一套名次排。
const latestResultsScopedSelect = `
SELECT r.id AS rid, r.event_id AS event_id, e.category_id AS category_id,
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, '') AS ts,
COALESCE(r.rank, cr.rank) AS rank_value
FROM results r
JOIN entries e ON e.id = r.entry_id` + mainChannelRankJoin + `
WHERE r.state = 'published' AND r.status <> 'withdrawn'`
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。 // loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
// 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行; // 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行;
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。 // 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
//
// 窗口是「K 组 × 每组 R 行」而不是「总 N 行」,理由见上面那组常量的注释。三件事都在 SQL 里定死:
//
// 1. recent_groups:先按**组的发布时间**(= 组内最新一条的时间)倒序取前 K 组。并列时间按
// 「该组最新那一行的 r.id」倒序决出——与旧实现「行按 ts DESC, r.id DESC 取回、组按首次出现建号」
// 的顺序**逐位相同**,组数组的顺序不变(`Snapshot.LatestResults` 是 WS 契约)。
// 2. bounded:每组按名次升序编号(无名次排最后,与下面 Go 侧那次 sort 同口径),只留前 R 行。
// ⚠️ 必须是「先定组、再每组截断」;退化成「取 N 行再分组」就还是同一个 bug,只是阈值变大。
// 3. 组的时间取 anchors 定的 group_ts(组内最新一条),不是「这一组碰巧先被取回的那一行」——
// 组内按名次截断之后,第一行的发布时间不再等于组的最新发布时间。
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, ` rows, err := tx.QueryContext(ctx, `
WITH scoped AS (`+latestResultsScopedSelect+`),
anchors AS (
SELECT event_id, category_id, ts AS group_ts, rid,
ROW_NUMBER() OVER (PARTITION BY event_id, category_id ORDER BY ts DESC, rid DESC) AS anchor_no
FROM scoped),
recent_groups AS (
SELECT event_id, category_id, group_ts
FROM anchors WHERE anchor_no = 1
ORDER BY group_ts DESC, rid DESC
LIMIT ?),
bounded AS (
SELECT rid, event_id, category_id,
ROW_NUMBER() OVER (PARTITION BY event_id, category_id
ORDER BY rank_value IS NULL, rank_value, rid) AS row_no
FROM scoped)
SELECT r.event_id, COALESCE(ev.code, ''), COALESCE(ev.name, ''), SELECT r.event_id, COALESCE(ev.code, ''), COALESCE(ev.name, ''),
e.category_id, COALESCE(cat.name, ''), e.category_id, COALESCE(cat.name, ''),
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''), rg.group_ts,
r.entry_id, r.rank, e.team_id, r.entry_id, COALESCE(r.rank, cr.rank), e.team_id,
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path, COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path,
e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''), e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''),
COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status
@@ -440,10 +526,13 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
JOIN events ev ON ev.id = r.event_id JOIN events ev ON ev.id = r.event_id
LEFT JOIN event_categories cat ON cat.id = e.category_id LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN teams t ON t.id = e.team_id LEFT JOIN teams t ON t.id = e.team_id
LEFT JOIN athletes a ON a.id = e.athlete_id LEFT JOIN athletes a ON a.id = e.athlete_id`+mainChannelRankJoin+`
WHERE r.state = 'published' AND r.status <> 'withdrawn' JOIN bounded b ON b.rid = r.id
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC JOIN recent_groups rg ON rg.event_id = b.event_id AND rg.category_id = b.category_id
LIMIT ?`, latestResultsLimit) WHERE r.state = 'published' AND r.status <> 'withdrawn' AND b.row_no <= ?
ORDER BY rg.group_ts DESC,
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`,
latestResultsGroupLimit, latestResultsRowsPerGroup)
if err != nil { if err != nil {
return fmt.Errorf("读取最新成绩: %w", err) return fmt.Errorf("读取最新成绩: %w", err)
} }
@@ -460,18 +549,20 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
eventID, categoryID int64 eventID, categoryID int64
eventCode, eventName string eventCode, eventName string
categoryName string categoryName string
publishedAt string // groupPublishedAt 是这一**组**的发布时间(组内最新一条),来自 SQL 的 group_ts,
row dto.EventResultRow // 不是这一行自己的时间——组内按名次截断后,第一行的时间不再等于组的最新时间。
rank sql.NullInt64 groupPublishedAt string
value sql.NullFloat64 row dto.EventResultRow
athleteID sql.NullInt64 rank sql.NullInt64
athleteName string value sql.NullFloat64
shortName string athleteID sql.NullInt64
logoPath sql.NullString athleteName string
memberNames string shortName string
isTie int logoPath sql.NullString
memberNames string
isTie int
) )
if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &publishedAt, if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &groupPublishedAt,
&row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &logoPath, &row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &logoPath,
&athleteID, &athleteName, &memberNames, &athleteID, &athleteName, &memberNames,
&row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil { &row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil {
@@ -506,7 +597,7 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
EventName: eventName, EventName: eventName,
CategoryID: categoryID, CategoryID: categoryID,
CategoryName: categoryName, CategoryName: categoryName,
PublishedAt: nilIfEmpty(publishedAt), PublishedAt: nilIfEmpty(groupPublishedAt),
Rows: []dto.EventResultRow{}, Rows: []dto.EventResultRow{},
}) })
} }
+373
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto" "git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
) )
@@ -722,3 +723,375 @@ func TestSnapshotTeamsEventsWireFieldsPresent(t *testing.T) {
} }
} }
} }
// insertTeamEvent 建一个纯团体小项(event_type=team),与现场「团体 4x100 接力」同形。
func insertTeamEvent(t *testing.T, db *sql.DB, code, name string) int64 {
t.Helper()
if _, err := db.Exec(`INSERT OR IGNORE INTO sports(code, name) VALUES ('S1', '田径')`); err != nil {
t.Fatalf("插入大项: %v", err)
}
res, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, ?, ?, 'team', 'auto', 'time', 'asc', 'published')`, code, name)
if err != nil {
t.Fatalf("插入团体小项 %s: %v", code, err)
}
return lastID(t, res)
}
// insertTeamResult 写一条团体成绩:rank 列保持 NULL。
// 团体名次有意不写 results.rank(applyComputedRanks 只认个人主体),发布后的真实形状就是这样。
func insertTeamResult(t *testing.T, db *sql.DB, eventID, entryID int64, text string, value float64, verifiedAt string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status, verified_at)
VALUES (?, ?, NULL, ?, ?, 's', 'published', 'ok', ?)`, eventID, entryID, text, value, verifiedAt)
if err != nil {
t.Fatalf("插入团体成绩: %v", err)
}
return lastID(t, res)
}
// latestRowsByEntry 把快照「最新成绩」摊成 entryID -> 名次,便于逐条断言。
func latestRowsByEntry(t *testing.T, snap *Snapshot) map[int64]*int {
t.Helper()
out := make(map[int64]*int)
for _, group := range snap.LatestResults {
for _, row := range group.Rows {
out[row.EntryID] = row.Rank
}
}
return out
}
// 团体项目的名次只落 channel_ranks、results.rank 有意留空,快照的 latestResults 必须回落到
// channel_ranks:不回落的现场表现就是「团体 4x100 接力」发布之后,大屏分项成绩页与底栏上的
// 名次整列都是「-」。库形态与发布后的真实落库一致(发布链路端到端钉在 httpapi 侧:
// TestDisplayShowsTeamRankForTeamEvent)。
func TestSnapshotLatestResultsFallsBackToChannelRanks(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "一队")
team2 := insertTeam(t, db, "T2", "第二单位", "二队")
eventID := insertTeamEvent(t, db, "T4X100", "4x100 米接力")
catID := insertCategory(t, db, eventID, "男子")
entry1 := insertEntry(t, db, eventID, catID, team1, 0)
entry2 := insertEntry(t, db, eventID, catID, team2, 0)
channelID := insertChannel(t, db, eventID, "team")
// 故意先写名次靠后的那条,验证组内按名次重排。
result2 := insertTeamResult(t, db, eventID, entry2, "46.10", 46.10, "2026-09-09T10:00:02Z")
result1 := insertTeamResult(t, db, eventID, entry1, "45.20", 45.20, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, eventID, catID, channelID, entry1, result1, team1, 0, "team", "result", 1, 45.20)
insertChannelRank(t, db, eventID, catID, channelID, entry2, result2, team2, 0, "team", "result", 2, 46.10)
var ranked int
if err := db.QueryRow(`SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, eventID).Scan(&ranked); err != nil {
t.Fatalf("统计团体名次写回: %v", err)
}
if ranked != 0 {
t.Fatalf("前提不成立:团体名次不该写回 results.rank,得到 %d 行", ranked)
}
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != 1 || len(snap.LatestResults[0].Rows) != 2 {
t.Fatalf("应有 1 组 2 行,得到 %+v", snap.LatestResults)
}
ranks := latestRowsByEntry(t, snap)
for entryID, want := range map[int64]int{entry1: 1, entry2: 2} {
got := ranks[entryID]
if got == nil {
t.Fatalf("报名项 %d 的名次为空(大屏上就是「-」,得到 %+v)", entryID, snap.LatestResults)
}
if *got != want {
t.Fatalf("报名项 %d 的名次 = %d,期望 %d(来自 channel_ranks)", entryID, *got, want)
}
}
if snap.LatestResults[0].Rows[0].EntryID != entry1 {
t.Fatalf("组内应按名次排序(第一条应是第 1 名的报名项 %d),得到 %+v",
entry1, snap.LatestResults[0].Rows)
}
}
// 名次优先级必须是 results.rank 优先、channel_ranks 兜底:
// 「只更新名次」的发布(rankOnly)只写 results.rank、不重建 channel_ranks,
// 若 cr 优先,个人项目在这种发布之后会退回上一次的旧名次(从「正常」变成「显示旧名次」)。
func TestSnapshotLatestResultsPrefersResultRank(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
eventID := insertEvent(t, db, "E1", "100米")
catID := insertCategory(t, db, eventID, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
entry1 := insertEntry(t, db, eventID, catID, team1, athlete1)
channelID := insertChannel(t, db, eventID, "athlete")
// results.rank = 2 是「只更新名次」那次发布写下的新名次;channel_ranks 还是上一次发布的 1。
result1 := insertResult(t, db, eventID, entry1, 2, "11.72", 11.72, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, eventID, catID, channelID, entry1, result1, team1, athlete1, "athlete", "result", 1, 11.72)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
got := latestRowsByEntry(t, snap)[entry1]
if got == nil {
t.Fatalf("名次为空(大屏上就是「-」),期望 results.rank 的 2")
}
if *got != 2 {
t.Fatalf("名次应是 results.rank 的 2(新名次),得到 %d", *got)
}
}
// shiftTS 把 ISO 时间往后推 n 秒,造数里用来让「名次越靠后、发布时间越晚」(= 从第 1 名往下发)。
func shiftTS(t *testing.T, base string, seconds int) string {
t.Helper()
ts, err := time.Parse(time.RFC3339, base)
if err != nil {
t.Fatalf("解析时间 %s: %v", base, err)
}
return ts.Add(time.Duration(seconds) * time.Second).Format(time.RFC3339)
}
// insertRankedGroup 造一组「小项 + 组别」:rows 条已发布成绩,名次 1..rows **按顺序录入**
// (results.id 随名次递增,第 1 名建号最早),返回小项 id。
// tsOf 给出每一行的发布时间:现场「从第 1 名往下录/往下发」时它随名次变晚,
// 而一次发布刷同一时间戳时它是常量(并列靠 r.id 决先后)。
func insertRankedGroup(t *testing.T, db *sql.DB, code, name string, teamID int64, rows int, tsOf func(rank int) string) int64 {
t.Helper()
eventID := insertEvent(t, db, code, name)
catID := insertCategory(t, db, eventID, "男子")
for rank := 1; rank <= rows; rank++ {
athlete := insertAthlete(t, db, fmt.Sprintf("%s-第%d名", code, rank), teamID)
entry := insertEntry(t, db, eventID, catID, teamID, athlete)
insertResult(t, db, eventID, entry, rank,
fmt.Sprintf("11.%02d", rank), 11+float64(rank)/100, tsOf(rank))
}
return eventID
}
// ranksOf 把一组的名次摊平成列表,只用于失败信息:直接 `%+v` 一整组行会打出一串指针地址。
func ranksOf(group dto.EventResultGroup) []string {
out := make([]string, 0, len(group.Rows))
for _, row := range group.Rows {
if row.Rank == nil {
out = append(out, "—")
continue
}
out = append(out, fmt.Sprint(*row.Rank))
}
return out
}
// 快照「最新成绩」的窗口是「最近 K 组 × 每组前 R 行」,不是「总 N 行」。
//
// 这条用例钉的是现场那个 bug:行按发布时间倒序取、组按「首次出现」建号 → **屏上最旧的那一栏
// 站在窗口尾部**,按总行数截断时切口只会落进那一组里,把「建号最早、也就是第 1 名」的行切掉。
// 现场表现就是「第 3 栏看不到第 1 名、从第 2 名开始」,而且症状跟着**栏位**走、不跟着项目走。
//
// 旧实现(全局 20 行窗口)在这里必然失败:24 行里最旧的 4 行(第 3 栏的第 1–4 名)被切掉,
// 第 3 栏第一行的名次是 5 而不是 1。既有用例拦不住它——那些用例每组最多 2 行、总量远小于 20。
func TestSnapshotLatestResultsKeepsRankOneInEveryGroup(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
const rowsPerGroup = 8
// 三栏的发布时间:第 3 栏(下标 2)最早发布、建号也最早,旧实现下正是它站在窗口尾部。
publishedBases := [3]string{"2026-09-09T10:00:00Z", "2026-09-09T11:00:00Z", "2026-09-09T09:00:00Z"}
events := make([]int64, len(publishedBases))
// 造数顺序刻意与栏位顺序相反:先发布「最终落在第 3 栏」的那一组,再发另外两组。
for _, col := range []int{2, 0, 1} {
base := publishedBases[col]
events[col] = insertRankedGroup(t, db, fmt.Sprintf("E%d", col+1),
fmt.Sprintf("大屏第 %d 栏的小项", col+1), team1, rowsPerGroup,
func(rank int) string { return shiftTS(t, base, rank) })
}
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
byEvent := make(map[int64]dto.EventResultGroup, len(snap.LatestResults))
totalRows := 0
for _, group := range snap.LatestResults {
byEvent[group.EventID] = group
totalRows += len(group.Rows)
}
if len(snap.LatestResults) < 3 {
t.Fatalf("三栏都应下发,得到 %d 组: %+v", len(snap.LatestResults), snap.LatestResults)
}
// 组数组的顺序不变(WS 契约):发布时间倒序,最旧的那一组在最尾部 = 屏上最后一栏。
if snap.LatestResults[len(snap.LatestResults)-1].EventID != events[2] {
t.Fatalf("最旧发布的组应排在组数组尾部(第 3 栏),得到 %+v",
[]int64{snap.LatestResults[0].EventID, snap.LatestResults[1].EventID, snap.LatestResults[2].EventID})
}
wantRows := min(rowsPerGroup, latestResultsRowsPerGroup)
for col, eventID := range events {
group, ok := byEvent[eventID]
if !ok {
t.Fatalf("第 %d 栏(小项 %d)整组都没下发: %+v", col+1, eventID, snap.LatestResults)
}
// 断言顺序有讲究:先看第 1 名在不在,失败信息才是这个 bug 的原始形状。
if group.Rows[0].Rank == nil || *group.Rows[0].Rank != 1 {
t.Fatalf("第 %d 栏应从第 1 名开始,实际名次 %v(整组行数 %d)", col+1, ranksOf(group), len(group.Rows))
}
for i, row := range group.Rows {
if row.Rank == nil || *row.Rank != i+1 {
t.Fatalf("第 %d 栏的名次应连续升序,实际 %v", col+1, ranksOf(group))
}
}
if len(group.Rows) != wantRows {
t.Fatalf("第 %d 栏应有 %d 行(每组上限 R=%d),实际名次 %v",
col+1, wantRows, latestResultsRowsPerGroup, ranksOf(group))
}
}
// 总行数 > 20 是用例的前提(旧口径正是从这里翻车),放最后断言:
// 失败信息才优先说清「是哪一栏少了第 1 名」。
if totalRows <= 20 {
t.Fatalf("用例前提不成立:总行数必须 > 20(旧实现翻车的条件),得到 %d", totalRows)
}
}
// 每组只保留**名次最靠前**的 R 行,且组的发布时间取组内最新一条(哪怕那一行被 R 截掉)。
//
// 这一条同时挡住两种退化:①「先按总行数截断再分组」(会留下 12 行、超过 R);
// ②「组内取发布时间最新的 R 行」(会少掉第 1 名)。旧实现在这里也是红的(12 行一条不落地留下)。
func TestSnapshotLatestResultsCapsRowsPerGroupByRank(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
base := "2026-09-09T10:00:00Z"
// 比 R 多 2 行:名次最靠后的两行必须被丢掉,第 1 名必须在。
rows := latestResultsRowsPerGroup + 2
eventID := insertRankedGroup(t, db, "E1", "男子 100 米", team1, rows,
func(rank int) string { return shiftTS(t, base, rank) })
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != 1 {
t.Fatalf("应只有 1 组,得到 %d: %+v", len(snap.LatestResults), snap.LatestResults)
}
group := snap.LatestResults[0]
if group.EventID != eventID {
t.Fatalf("组的小项应是 %d,得到 %d", eventID, group.EventID)
}
if len(group.Rows) != latestResultsRowsPerGroup {
t.Fatalf("一组最多 %d 行,实际名次 %v", latestResultsRowsPerGroup, ranksOf(group))
}
for i, row := range group.Rows {
if row.Rank == nil || *row.Rank != i+1 {
t.Fatalf("留下的必须是名次最靠前的 %d 行,实际名次 %v", latestResultsRowsPerGroup, ranksOf(group))
}
}
// 组的发布时间 = 组内最新一条(第 12 名那行的 base+12s),不是「留下的第一行」的 base+1s。
wantPublishedAt := shiftTS(t, base, rows)
if group.PublishedAt == nil || *group.PublishedAt != wantPublishedAt {
t.Fatalf("组的发布时间应取组内最新一条 %s,得到 %v", wantPublishedAt, group.PublishedAt)
}
}
// 组数超过 K 时只保留**最近发布**的 K 组:组数组尾部(屏上最后一栏)先被丢掉。
func TestSnapshotLatestResultsCapsGroupCount(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
base := "2026-09-09T10:00:00Z"
const groupCount = latestResultsGroupLimit + 1
events := make([]int64, groupCount)
for i := 0; i < groupCount; i++ {
// 每组 1 行,发布时间依次变新:下标越大越新。
ts := shiftTS(t, base, i*60)
events[i] = insertRankedGroup(t, db, fmt.Sprintf("E%02d", i+1),
fmt.Sprintf("第 %d 个小项", i+1), team1, 1,
func(int) string { return ts })
}
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != latestResultsGroupLimit {
t.Fatalf("组数应封顶在 K=%d,得到 %d", latestResultsGroupLimit, len(snap.LatestResults))
}
if snap.LatestResults[0].EventID != events[groupCount-1] {
t.Fatalf("最新发布的组应排在最前,得到小项 %d,期望 %d", snap.LatestResults[0].EventID, events[groupCount-1])
}
kept := make(map[int64]bool, len(snap.LatestResults))
for _, group := range snap.LatestResults {
kept[group.EventID] = true
}
if kept[events[0]] {
t.Fatalf("最旧的那一组应被丢掉: %+v", snap.LatestResults)
}
for i := 1; i < groupCount; i++ {
if !kept[events[i]] {
t.Fatalf("最近发布的 %d 组都应保留,缺少小项 %d: %+v", latestResultsGroupLimit, events[i], snap.LatestResults)
}
}
}
// 一次发布会把范围内的行刷成**同一个** verified_at(httpapi/scoring.go 的 setPublished 用
// datetime('now')),所以现场最常见的形态是「组内行时间全并列、跨组也可能并列」。
// 并列时按 r.id 决先后,组的顺序必须与旧实现逐位相同(行按 ts DESC, r.id DESC 取回、
// 组按首次出现建号 → 时间并列时等于「组内最大 r.id」倒序),否则大屏的栏位会换位置。
func TestSnapshotLatestResultsTiedPublishTimestamps(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
// 三组同一次发布:9:30:00 那一秒。
const sameTS = "2026-09-09T09:30:00Z"
const rowsPerGroup = 8
events := make([]int64, 3)
// 造数顺序 = 发布顺序:先发第 3 栏那一组(建号最早),再发第 1、2 栏。
insertOrder := []int{2, 0, 1}
for _, col := range insertOrder {
events[col] = insertRankedGroup(t, db, fmt.Sprintf("E%d", col+1),
fmt.Sprintf("大屏第 %d 栏的小项", col+1), team1, rowsPerGroup,
func(int) string { return sameTS })
}
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != 3 {
t.Fatalf("三组都应下发,得到 %d 组", len(snap.LatestResults))
}
// 时间并列 → 靠 r.id 决先后:最后发布的那一组(第 2 栏)在最前,最先发布的(第 3 栏)在最后。
wantOrder := []int{1, 0, 2}
for i, col := range wantOrder {
if snap.LatestResults[i].EventID != events[col] {
t.Fatalf("并列时间的组顺序应与发布顺序相反,第 %d 位应是第 %d 栏的小项 %d,得到 %d",
i+1, col+1, events[col], snap.LatestResults[i].EventID)
}
}
byEvent := make(map[int64]dto.EventResultGroup, len(snap.LatestResults))
for _, group := range snap.LatestResults {
byEvent[group.EventID] = group
}
for col, eventID := range events {
group := byEvent[eventID]
if group.Rows[0].Rank == nil || *group.Rows[0].Rank != 1 {
t.Fatalf("第 %d 栏应从第 1 名开始,实际名次 %v", col+1, ranksOf(group))
}
if len(group.Rows) != rowsPerGroup {
t.Fatalf("第 %d 栏应有 %d 行,实际名次 %v", col+1, rowsPerGroup, ranksOf(group))
}
// 并列时间下组的发布时间也是同一条,不能变成「某一行的时间」以外的值。
if group.PublishedAt == nil || *group.PublishedAt != sameTS {
t.Fatalf("第 %d 栏的发布时间应是 %s,得到 %v", col+1, sameTS, group.PublishedAt)
}
}
}
+9
View File
@@ -328,6 +328,15 @@ export const adminApi = {
() => [], () => [],
), ),
/**
* 给某台大屏设 / 清别名(`PUT /api/display/instances/{id}/alias`)。
*
* 权限与大屏切页同档(录入员及以上),所以用 callWrite:写操作一律不降级——示例数据模式下
* 返回「改好了」会让操作员以为名字存住了,而它只活在内存里。空串 = 清除别名、回到自动标签。
*/
displayAlias: (id: string, alias: string): Promise<DisplayInstance> =>
callWrite(() => client.display.setAlias(id, alias)),
/** /**
* 「实时赛况」当前项目(服务端按设置与时序判定)。 * 「实时赛况」当前项目(服务端按设置与时序判定)。
* *
@@ -119,6 +119,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
<ul v-if="details.length > 0" class="confirm-details"> <ul v-if="details.length > 0" class="confirm-details">
<li v-for="(line, index) in details" :key="index">{{ line }}</li> <li v-for="(line, index) in details" :key="index">{{ line }}</li>
</ul> </ul>
<!--
调用方自定义的那一问(录入页的「本次怎么发」二选一挂在这里)。
做成插槽而不是再弹一个窗:口径属于这次确认的一部分,分开问会多一次点击、
也多一个「关掉第一个弹窗之后什么都没发生」的中间态。
-->
<slot></slot>
<!-- <!--
原因输入:pending 时用 readonly 而不是 disabled——请求已经在服务端跑了, 原因输入:pending 时用 readonly 而不是 disabled——请求已经在服务端跑了,
这时候改文字不会被提交(弹窗本身也锁死了,关不掉),但不该把已填的内容抹成灰。 这时候改文字不会被提交(弹窗本身也锁死了,关不掉),但不该把已填的内容抹成灰。
@@ -87,21 +87,62 @@ export const COLUMN_ALIASES: Record<ImportColumnName, readonly string[]> = {
squad: ['队伍', '队名', '队伍名'], squad: ['队伍', '队名', '队伍名'],
} }
/**
* 折叠后的文本 + 「折叠文本第 i 个 UTF-16 单元来自原文第几个码点」的映射。
*
* `origin` 是给高亮用的:命中位置是在折叠后的串上找到的,要还原回原文字符下标才能切片,
* 而折叠会丢掉 U+FEFF、小写偶有变长(如 İ),不能拿折叠串的下标直接当原文下标。
*/
export interface FoldedText {
text: string
origin: number[]
}
/**
* 逐码点折叠:全角空格(U+3000)→ 半角空格、全角 ASCII(!-~)→ 半角、丢掉 U+FEFF、转小写。
*
* 现场的表是中文 Excel 存的、名字多半是从聊天记录里粘过来的:列名常带全角空格、
* 被输入法写成全角字母、第一格粘着 BOM。搜索关键字同理(中文输入法下打出来的空格就是全角),
* 两边同一口径折叠,看起来一样(甚至多一个看不见的全角空格)的两个串才搜得到。
*
* 按码点迭代(不是逐 UTF-16 单元):代理对(emoji、生僻字)不会被拆成两半。
*/
export function foldText(raw: string): FoldedText {
let text = ''
const origin: number[] = []
for (let index = 0; index < raw.length; ) {
const code = raw.codePointAt(index) ?? 0
const size = code > 0xffff ? 2 : 1
const char =
code === 0xfeff
? ''
: code === 0x3000
? ' '
: code >= 0xff01 && code <= 0xff5e
? String.fromCharCode(code - 0xfee0)
: String.fromCodePoint(code)
const folded = char.toLowerCase()
for (let unit = 0; unit < folded.length; unit += 1) origin.push(index)
text += folded
index += size
}
return { text, origin }
}
/** 搜索 / 表头归一用的折叠文本:再去掉首尾空白(两边同口径,所以「张三 」与「张三」等价)。 */
export function foldSearchText(raw: string): string {
return foldText(raw).text.trim()
}
/** /**
* 表头格归一:去首尾空白、全角 ASCII 与全角空格转半角、丢掉 U+FEFF、转小写。 * 表头格归一:去首尾空白、全角 ASCII 与全角空格转半角、丢掉 U+FEFF、转小写。
* 与服务端 normalizeImportHeader 同一口径——现场的表是中文 Excel 存的, * 与服务端 normalizeImportHeader 同一口径——现场的表是中文 Excel 存的,
* 列名常带全角空格 / 被输入法写成全角字母 / 第一格粘着 BOM。 * 列名常带全角空格 / 被输入法写成全角字母 / 第一格粘着 BOM。
*
* 与搜索框共用同一份折叠实现(foldSearchText):口径分两份写,迟早有一边忘了改。
*/ */
export function normalizeHeaderCell(raw: string): string { export function normalizeHeaderCell(raw: string): string {
let folded = '' return foldSearchText(raw)
for (const char of raw) {
const code = char.codePointAt(0) ?? 0
if (code === 0xfeff) continue
else if (code === 0x3000) folded += ' '
else if (code >= 0xff01 && code <= 0xff5e) folded += String.fromCharCode(code - 0xfee0)
else folded += char
}
return folded.trim().toLowerCase()
} }
/** 认一格表头,返回规范列名;认不出的(多余列、备注列)返回 undefined 由调用方忽略。 */ /** 认一格表头,返回规范列名;认不出的(多余列、备注列)返回 undefined 由调用方忽略。 */
@@ -218,7 +259,46 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
} }
/** /**
* 选人搜索框的匹配口径:**姓名 / 工号 / 号码布**任一命中即算(大小写不敏感、首尾空格忽略)。 * 一维文本是否命中关键字:两边都按同一口径折叠(大小写不敏感、首尾与全角空格忽略)后包含。
*
* 关键字折叠后为空 = 没在搜,返回 true(不能把名单筛空)。
*
* 搜索框是文本框,这里只做字符串比较;**不要**把它的取值喂给 `type="number"` 的输入框
* (那种框的 v-model 会被 Vue 转成 number,清空才留 '',字符串方法会在渲染期抛错)。
*/
export function textMatchesKeyword(text: string, keyword: string): boolean {
const needle = foldSearchText(keyword)
if (needle === '') return true
return foldSearchText(text).includes(needle)
}
/**
* 号码布是否命中关键字:先按文本包含(与姓名同口径),再按**数字**比一次。
*
* 号码布是手写编号录进系统的,同一批人里「007」与「7」两种写法都会有:只做字符串包含时
* 搜「7」就漏掉带前导零的那批人——而现场看号码布只看它是几号。所以两侧都是纯数字时
* 去掉前导零后相等也算命中(007 与 7、07 与 7 互相都能搜到),不是纯数字(A001、空串)时
* 只走文本口径。
*/
export function bibMatchesKeyword(bib: string | null | undefined, keyword: string): boolean {
const raw = bib ?? ''
if (textMatchesKeyword(raw, keyword)) return true
const left = bibDigits(raw)
const right = bibDigits(foldSearchText(keyword))
return left !== null && right !== null && left === right
}
/** 纯数字号码布去掉前导零后的样子(0 仍是 0);不是纯数字时返回 null。 */
function bibDigits(value: string): string | null {
const digits = value.trim()
if (!/^\d+$/.test(digits)) return null
const stripped = digits.replace(/^0+/, '')
return stripped === '' ? '0' : stripped
}
/**
* 选人搜索框的匹配口径:**姓名 / 工号 / 号码布**任一命中即算
* (大小写不敏感、首尾空格与全角空格忽略,见 foldSearchText)。
* *
* 号码布必须在内:它是本系统里人员的唯一标识(先认证件号,没有证件号就看号码布), * 号码布必须在内:它是本系统里人员的唯一标识(先认证件号,没有证件号就看号码布),
* 现场拿着号码布找人是常态,只能按姓名 / 工号搜等于搜不到人。 * 现场拿着号码布找人是常态,只能按姓名 / 工号搜等于搜不到人。
@@ -226,19 +306,17 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
* 工号与号码布都可能为空(老数据两个都没填、老服务端也不下发),一律当空串处理。 * 工号与号码布都可能为空(老数据两个都没填、老服务端也不下发),一律当空串处理。
* 关键字全为空白 = 没在搜,返回 true(不能把名单筛空)。 * 关键字全为空白 = 没在搜,返回 true(不能把名单筛空)。
* *
* 搜索框是文本框,这里只做字符串比较;**不要**把它的取值喂给 `type="number"` 的输入框 * 录入表格的搜人框(pages/entrySearch.ts)也走这一份:姓名 / 号码布的口径只有这一处实现,
* (那种框的 v-model 会被 Vue 转成 number,清空才留 '',字符串方法会在渲染期抛错)。 * 两处各写一份迟早会分叉(一边能搜号码布、另一边不能)。
*/ */
export function athleteMatchesKeyword( export function athleteMatchesKeyword(
athlete: { name: string; employeeNo?: string | null; bib?: string | null }, athlete: { name: string; employeeNo?: string | null; bib?: string | null },
keyword: string, keyword: string,
): boolean { ): boolean {
const needle = keyword.trim().toLowerCase()
if (needle === '') return true
return ( return (
athlete.name.toLowerCase().includes(needle) || textMatchesKeyword(athlete.name, keyword) ||
(athlete.employeeNo ?? '').toLowerCase().includes(needle) || textMatchesKeyword(athlete.employeeNo ?? '', keyword) ||
(athlete.bib ?? '').toLowerCase().includes(needle) bibMatchesKeyword(athlete.bib, keyword)
) )
} }
+294 -8
View File
@@ -12,13 +12,24 @@ import { settingsApi } from '../components/system/systemApi'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event' import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast' import { useToastStore } from '../stores/toast'
import {
ALIAS_CLEAR_HINT,
ALIAS_RENAME_HINT,
aliasPayload,
instanceDisplayName,
instanceIdNote,
instanceTooltip,
} from './displayInstanceLabel'
import { import {
copyText, copyText,
isLanQrKeepTarget,
lanDisplayLinks, lanDisplayLinks,
lanEmptyNote, lanEmptyNote,
manualAddressStorage, manualAddressStorage,
parseManualAddress, parseManualAddress,
readManualAddresses, readManualAddresses,
showsQrButton,
toggleQr,
writeManualAddresses, writeManualAddresses,
} from './lanDisplay' } from './lanDisplay'
@@ -328,6 +339,75 @@ function onVisibilityChange(): void {
if (document.visibilityState === 'visible') void refreshInstances() if (document.visibilityState === 'visible') void refreshInstances()
} }
// ── 给某台设备改名(手动别名,机器级、跟着实例 id 走) ────────────────────────
/** 正在改名的那一行;null = 都不在编辑态。同一时刻只开一个输入框,避免几行一起飘着。 */
const renamingId = ref<string | null>(null)
const renameDraft = ref('')
/** 正在提交的那一行,用来挡住重复点击。 */
const renamePending = ref<string | null>(null)
/**
* 改名按钮的禁用原因(与切页按钮同一口径:未登录 / 服务端未连接)。
*
* 权限与大屏切页同档 = 录入员及以上,所以 gate 只跟 canWrite 走,不再额外要管理员。
*/
const renameGateReason = computed(() =>
canWrite.value ? null : auth.readOnly ? '请先登录后再改名。' : '服务端未连接,先恢复连接再改名。',
)
/** 打开某行的输入框;预填手动别名(自动标签只是兜底,别预填成用户以为「已经设过」的名字)。 */
function startRename(instance: DisplayInstance): void {
renamingId.value = instance.id
renameDraft.value = instance.alias ?? ''
}
function cancelRename(): void {
renamingId.value = null
renameDraft.value = ''
}
/**
* 提交别名。空串 = 清除(服务端口径),所以「保存」与「清除」共用这一条路径。
*
* 成功后**立刻刷新列表**:别名是服务端持久化的,本地那一行自己改改只能骗自己的眼睛;
* 失败一律把服务端原文交给 toast(403 / 404 各说各的话,不能吞成一句「操作失败」)。
*/
async function submitAlias(instance: DisplayInstance, alias: string): Promise<void> {
if (!canWrite.value) {
toast.error('当前不可改名', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (data.isMock) {
// 示例数据模式下没有真实服务端,存不了别名;谎报成功会让操作员以为名字记住了。
toast.warning('示例数据模式未真正改名', '后端未就绪,名字不会存下来')
return
}
if (renamePending.value !== null) return
renamePending.value = instance.id
try {
await adminApi.displayAlias(instance.id, alias)
cancelRename()
await refreshInstances()
if (alias === '') toast.success('已清除手动名字', '这台设备回到浏览器报上来的设备标签')
else toast.success(`已改名为「${alias}」`, '只对这台设备生效,刷新与重连都不会丢')
} catch (caught) {
toast.error('改名失败', caught instanceof Error ? caught.message : null)
} finally {
renamePending.value = null
}
}
/** 保存输入框里的内容;首尾空白在这里去掉(粘贴进来的名字几乎都带着)。 */
function saveAlias(instance: DisplayInstance): void {
void submitAlias(instance, aliasPayload(renameDraft.value).alias)
}
/** 清除别名:直接提交空串,不看输入框里现在是什么。 */
function clearAlias(instance: DisplayInstance): void {
void submitAlias(instance, '')
}
// ── 局域网大屏(别的设备用浏览器打开就是一块大屏) ──────────────────────────── // ── 局域网大屏(别的设备用浏览器打开就是一块大屏) ────────────────────────────
const lanInfo = ref<NetworkInfo | null>(null) const lanInfo = ref<NetworkInfo | null>(null)
@@ -412,19 +492,107 @@ const lanPortMissing = computed(() => lanInfo.value !== null && lanInfo.value.po
const lanManualEnabled = computed(() => lanInfo.value !== null && !lanPortMissing.value) const lanManualEnabled = computed(() => lanInfo.value !== null && !lanPortMissing.value)
/** /**
* 第一个地址的小二维码。 * 给一条地址出二维码 SVG。
* *
* 只给第一条地址出码:卡片上摆一排二维码没有意义,现场也只需要一个能随手扫的入口。
* scale 由模块数反推,让不同版本(21–177 模块)渲染出来都落在接近的像素尺寸上。 * scale 由模块数反推,让不同版本(21–177 模块)渲染出来都落在接近的像素尺寸上。
* 只有「首条常驻码」与「点开的那一条」两条路会调到这里——见下面两个 computed。
*/
function qrSvgFor(url: string): string {
const size = encodeQr(url).size
const scale = Math.max(1, Math.round(QR_PIXELS / (size + QR_MARGIN * 2)))
return qrToSvg(url, { scale, margin: QR_MARGIN, ecc: 'M' })
}
/**
* 第一个地址常驻的小二维码。
*
* 只给第一条地址出常驻码:卡片上摆一排二维码会把首屏拖慢(一个码是几百个 `<rect>`),
* 现场也只需要一个能随手扫的入口;其余各条改成点按钮临时出码(见 openLanQrSvg)。
*/ */
const lanQrSvg = computed(() => { const lanQrSvg = computed(() => {
const first = lanLinks.value[0] const first = lanLinks.value[0]
if (first === undefined) return null if (first === undefined) return null
const size = encodeQr(first.url).size return qrSvgFor(first.url)
const scale = Math.max(1, Math.round(QR_PIXELS / (size + QR_MARGIN * 2)))
return qrToSvg(first.url, { scale, margin: QR_MARGIN, ecc: 'M' })
}) })
/**
* 当前临时展开的是哪一条地址,`null` 表示都没展开。
*
* 存 IP 而不是下标:手填地址会插到列表**前面**,下标整体后移——拿下标当状态的话,加一条
* 地址会让已经打开的码悄悄换成另一条地址的码(扫出来是别的 IP,现场看不出来)。
* 地址列表的 key 也是 IP,两处口径一致。
*/
const openLanQrIp = ref<string | null>(null)
/**
* 临时展开那一条地址的二维码(懒生成)。
*
* 第一句就返回:没有展开的地址时**一个码都不算**。这是硬要求而不是优化——在渲染时为每条
* 地址都算一次 `qrToSvg`,卡片上就等于摆了一排二维码,首屏会被几百个 `<rect>` 拖慢。
* 所以模板里只读这个 computed,绝不在 `v-for` 里直接调 `qrToSvg`。
*/
const openLanQrSvg = computed(() => {
const ip = openLanQrIp.value
if (ip === null) return null
const link = lanLinks.value.find((item) => item.ip === ip)
if (link === undefined) return null
return qrSvgFor(link.url)
})
/** 点按钮:同一条再点一次=收起,点另一条=切过去(同一时刻只会有一个码)。 */
function toggleLanQr(ip: string): void {
openLanQrIp.value = toggleQr(openLanQrIp.value, ip)
}
/**
* 「点空白处关闭」用 document 上的 click(冒泡阶段,最后才轮到它)。
*
* 为什么是 click 而不是 pointerdown / mousedown:收码会让卡片当场变矮(一个二维码约占 150px),
* 而按下与松开之间隔着这一下重排——码下方那些按钮(「手动添加」的「添加」、下面几行的「复制」)
* 会在按住的一瞬间往上跳,松开时指针已经不在按钮上了,浏览器就不再派发 click:用户看到的是
* 「点了没反应」。真 Chromium 复现过(码开着时点「添加」不生效,移动端更容易碰上)。改成 click
* 后,收码发生在按钮自己的处理函数跑完之后,重排不会再吞掉这一次点击。
*
* 为什么按需挂而不是挂一个常驻的:这页是长开窗口(大屏旁边一直开着),常驻监听每次空白点击
* 都要白跑一遍;按需挂只在「有码」这一小段时间里有开销,且与码同生共死,不会出现「码早关了、
* 监听还挂着」。
*/
function onLanQrDismissClick(event: MouseEvent): void {
// 点在自己的按钮或二维码容器里不算「点空白处」:关闭监听在冒泡末端,不排除按钮的话,按钮
// 刚把码打开、紧接着就轮到它把码关掉(表现为按钮点了没反应);容器内的点击(长按识别、
// 选中地址)也不该收码。判断本身在 lanDisplay 里(isLanQrKeepTarget),那里能跑测试。
const target = event.target
if (target instanceof Element && isLanQrKeepTarget(target)) return
openLanQrIp.value = null
}
/**
* Esc 关闭同样挂在 document 上。
*
* 不写在按钮上的 `@keydown.esc`:焦点并不保证在按钮上(触摸屏点完按钮后焦点可能落在别处,
* Safari 里按钮根本不接收键盘焦点),那时按 Esc 会没反应。挂在 document 上就没有这个前提。
*/
function onLanQrDismissKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') openLanQrIp.value = null
}
function attachLanQrDismiss(): void {
document.addEventListener('click', onLanQrDismissClick)
document.addEventListener('keydown', onLanQrDismissKeydown)
}
function detachLanQrDismiss(): void {
document.removeEventListener('click', onLanQrDismissClick)
document.removeEventListener('keydown', onLanQrDismissKeydown)
}
// 有码打开就挂上两个监听,关掉(点空白处 / Esc / 再点按钮 / 那条地址被删)就摘掉。
// 监听的是「码真的在屏幕上」,所以上面任何一条关闭路径都自动覆盖,不必每处都记得摘。
watch(
() => openLanQrSvg.value !== null,
(open) => (open ? attachLanQrDismiss() : detachLanQrDismiss()),
)
async function refreshLan(): Promise<void> { async function refreshLan(): Promise<void> {
// 身份还没就绪时不要「把访客理由写进 lanProblem 就完事」:auth 是异步加载的, // 身份还没就绪时不要「把访客理由写进 lanProblem 就完事」:auth 是异步加载的,
// 挂载那一刻 readOnly 必然为 true,写死之后即使随后拿到管理员身份也没有东西再触发本函数 // 挂载那一刻 readOnly 必然为 true,写死之后即使随后拿到管理员身份也没有东西再触发本函数
@@ -486,6 +654,9 @@ onBeforeUnmount(() => {
clearTimeout(copiedTimer) clearTimeout(copiedTimer)
copiedTimer = null copiedTimer = null
} }
// 临时二维码的两个监听也在这里兜底摘掉:页面被切走时如果还开着码,watch 不会替我们摘
// (组件卸载时状态没人再改),漏摘会随每次进页面越积越多。
detachLanQrDismiss()
document.removeEventListener('visibilitychange', onVisibilityChange) document.removeEventListener('visibilitychange', onVisibilityChange)
}) })
@@ -998,6 +1169,22 @@ async function send(
<MxIcon :name="copiedUrl === item.url ? 'check' : 'copy'" :size="14" /> <MxIcon :name="copiedUrl === item.url ? 'check' : 'copy'" :size="14" />
{{ copiedUrl === item.url ? '已复制' : '复制' }} {{ copiedUrl === item.url ? '已复制' : '复制' }}
</button> </button>
<!--
非首条的地址没有常驻二维码(那会把首屏拖成一排码),改为点这里临时出一个。
data-lan-qr-keep:「点空白处关闭」的 document 监听靠它认出「这是自己的按钮」
(那个监听在冒泡末端,不排除的话它会先关掉、按钮的处理函数再开一次)。
-->
<button
v-if="showsQrButton(index)"
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
data-lan-qr-keep
:aria-expanded="openLanQrIp === item.ip"
title="点击显示二维码(点空白处关闭)"
@click="toggleLanQr(item.ip)"
>
二维码
</button>
</div> </div>
<div v-if="index === 0 && lanQrSvg !== null" class="lan-qr"> <div v-if="index === 0 && lanQrSvg !== null" class="lan-qr">
<!-- <!--
@@ -1007,6 +1194,15 @@ async function send(
<div class="lan-qr-code" v-html="lanQrSvg"></div> <div class="lan-qr-code" v-html="lanQrSvg"></div>
<div class="lan-qr-note">手机扫码直接打开</div> <div class="lan-qr-note">手机扫码直接打开</div>
</div> </div>
<!--
临时二维码:只渲染「当前打开的那一条」(openLanQrSvg 只对那一条算码),
点空白处 / 按 Esc / 再点一次按钮都能关掉,点开另一条则自动把这条收掉。
这里同样带 data-lan-qr-keep:容器内的点击(长按识别、选中地址)不算点空白处。
-->
<div v-if="openLanQrIp === item.ip && openLanQrSvg !== null" class="lan-qr" data-lan-qr-keep>
<div class="lan-qr-code" v-html="openLanQrSvg"></div>
<div class="lan-qr-note">手机扫码直接打开(再点一次「二维码」或点空白处收起)</div>
</div>
<!-- <!--
来源不常规的地址(up 位读不到 / 按默认路由推断)照常给链接与二维码——它们往往 来源不常规的地址(up 位读不到 / 按默认路由推断)照常给链接与二维码——它们往往
真能用——只是说清这条是怎么来的:现场遇到「扫了打不开」时才知道换下一条, 真能用——只是说清这条是怎么来的:现场遇到「扫了打不开」时才知道换下一条,
@@ -1072,9 +1268,18 @@ async function send(
</p> </p>
<div v-for="instance in instances" :key="instance.id" class="display-row"> <div v-for="instance in instances" :key="instance.id" class="display-row">
<div class="display-meta"> <!--
悬浮提示挂在信息块上(不是整行):排障要看的那几行都在这里,而右侧的下拉与按钮
各自有 title,别被这一条顶掉。
-->
<div class="display-meta" :title="instanceTooltip(instance)">
<div class="display-name"> <div class="display-name">
<span class="instance-id">{{ instance.id }}</span> <!-- 名字优先级:手动别名 → 自动设备标签 → 实例 id(纯函数,见 displayInstanceLabel.ts) -->
<span class="instance-name">{{ instanceDisplayName(instance) }}</span>
<!-- 名字不是 id 时,id 降级成一行小字:切页指令点名用的就是它,排障时还得拿得到。 -->
<span v-if="instanceIdNote(instance) !== null" class="instance-id-note">
{{ instanceIdNote(instance) }}
</span>
<span class="mx-badge" :class="instance.online ? 'mx-badge-success' : 'mx-badge-error'"> <span class="mx-badge" :class="instance.online ? 'mx-badge-success' : 'mx-badge-error'">
{{ instance.online ? '在线' : '离线' }} {{ instance.online ? '在线' : '离线' }}
</span> </span>
@@ -1088,6 +1293,50 @@ async function send(
</div> </div>
<div class="display-sub">最后心跳:{{ formatDateTime(instance.lastHeartbeat) }}</div> <div class="display-sub">最后心跳:{{ formatDateTime(instance.lastHeartbeat) }}</div>
<div v-if="!instance.online" class="display-sub">已离线:指令发不到它,等大屏重新连上再切页。</div> <div v-if="!instance.online" class="display-sub">已离线:指令发不到它,等大屏重新连上再切页。</div>
<!-- 行内改名的输入框:一次只开一个,保存 / 清除都打同一个接口。 -->
<div v-if="renamingId === instance.id" class="rename-row">
<div class="mx-input-wrap rename-input-wrap">
<input
v-model="renameDraft"
class="mx-input rename-input"
type="text"
placeholder="二号楼平板"
:aria-label="`给 ${instance.id} 改名`"
@keyup.enter="saveAlias(instance)"
@keyup.esc="cancelRename"
/>
</div>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="renamePending === instance.id"
:title="ALIAS_RENAME_HINT"
@click="saveAlias(instance)"
>
{{ renamePending === instance.id ? '保存中…' : '保存' }}
</button>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="renamePending === instance.id"
:title="ALIAS_CLEAR_HINT"
@click="clearAlias(instance)"
>
清除
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="renamePending === instance.id"
@click="cancelRename"
>
取消
</button>
</div>
<p v-if="renamingId === instance.id" class="display-sub rename-note">
清除后回到浏览器报上来的设备标签;名字跟着这台设备,刷新与重连都不会丢。
</p>
</div> </div>
<div class="display-actions"> <div class="display-actions">
<select <select
@@ -1106,6 +1355,16 @@ async function send(
> >
{{ targetPending === `instance:${instance.id}` ? '下发中…' : '切到此页' }} {{ targetPending === `instance:${instance.id}` ? '下发中…' : '切到此页' }}
</button> </button>
<!-- 改名的开关:只读访客 / 未连接服务端时置灰,并用 title 说清为什么。 -->
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!canWrite || renamePending !== null"
:title="renameGateReason ?? ALIAS_RENAME_HINT"
@click="startRename(instance)"
>
改名
</button>
</div> </div>
</div> </div>
@@ -1113,6 +1372,9 @@ async function send(
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 全部大屏页面 {{ pageLabel(currentPage) }}</p> <p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 全部大屏页面 {{ pageLabel(currentPage) }}</p>
<p v-if="instancesUpdatedAt !== null" class="muted">最近一次刷新:{{ formatDateTime(instancesUpdatedAt) }}</p> <p v-if="instancesUpdatedAt !== null" class="muted">最近一次刷新:{{ formatDateTime(instancesUpdatedAt) }}</p>
<p class="muted">「落后」= 该实例确认的版本落后(大屏可能在卡或网络差);离线实例会在服务端保留一段时间,便于排查。</p> <p class="muted">「落后」= 该实例确认的版本落后(大屏可能在卡或网络差);离线实例会在服务端保留一段时间,便于排查。</p>
<p class="muted">
名字优先显示手动改的(点「改名」),其次是浏览器报上来的设备信息,都没有才显示实例 id;把鼠标停在条目上能看到原始 UA 与实例 id。
</p>
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p> <p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
</div> </div>
</div> </div>
@@ -1429,11 +1691,35 @@ async function send(
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
} }
.instance-id { .instance-name {
overflow-wrap: anywhere;
}
/* 名字已经是别名 / 设备标签时,实例 id 降级成一行小字(切页指令点名的就是它)。 */
.instance-id-note {
font-family: var(--mx-mono); font-family: var(--mx-mono);
font-size: 12px; font-size: 12px;
font-weight: 400;
color: var(--mx-t3);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* 行内改名的输入框:占满信息块宽度,按钮跟在后面一排。 */
.rename-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.rename-input-wrap {
flex: 1;
min-width: 120px;
}
.rename-input {
height: 30px;
}
.rename-note {
margin-top: 6px;
}
.instance-page-select, .instance-page-select,
.screen-page-select { .screen-page-select {
width: 132px; width: 132px;
+379 -60
View File
@@ -52,7 +52,23 @@ import {
teamRanksPayload, teamRanksPayload,
type TeamRankRow, type TeamRankRow,
} from './teamRanks' } from './teamRanks'
import { athleteCellName, inlineBibLabel } from './entryRowDisplay' import {
athleteCellClass,
athleteCellName,
athleteToggleLabel,
athleteToggleTitle,
inlineBibLabel,
shouldOfferAthleteToggle,
} from './entryRowDisplay'
import {
SEARCH_EMPTY_HINT,
SEARCH_SCOPE_NOTE,
filterEntryRows,
highlightSegments,
isSearching,
searchCountText,
searchEmptyTitle,
} from './entrySearch'
import { import {
addShareDraftTeam, addShareDraftTeam,
removeShareDraftTeam, removeShareDraftTeam,
@@ -139,15 +155,17 @@ import {
rememberEntryEventId, rememberEntryEventId,
} from './entryInitialEvent' } from './entryInitialEvent'
import { import {
FINAL_PUBLISH_LABEL,
FINAL_PUBLISH_TITLE,
PREVIEW_RANK_ONLY_HINT, PREVIEW_RANK_ONLY_HINT,
PUBLISH_BUTTON_TITLE,
PUBLISH_OPTIONS,
boardModeNote, boardModeNote,
publishModeBadge, defaultPublishChoice,
openPublishChoice,
publishChoiceIsFinal,
publishModeConfirm, publishModeConfirm,
publishModeHint,
publishedPointsOnBoard, publishedPointsOnBoard,
rankOnlyOf, rankOnlyOf,
type PublishChoice,
} from './entryPublish' } from './entryPublish'
import { computeEventScore, type ScorableRow } from '../api/scoring' import { computeEventScore, type ScorableRow } from '../api/scoring'
import EntryPicker from '../components/baseData/EntryPicker.vue' import EntryPicker from '../components/baseData/EntryPicker.vue'
@@ -268,12 +286,15 @@ const clearDialog = ref(false)
const clearFilter = ref<HeatFilter>('all') const clearFilter = ref<HeatFilter>('all')
const clearing = ref(false) const clearing = ref(false)
/** /**
* 「以决赛成绩发布」勾选状态,**默认不勾**(不勾 = 本次只更新名次)。 * 发布弹窗里选中的口径,**默认「只更新名次」**(不主动让操作员把预赛当决赛算分)。
* *
* 每次发布成功后就复位成不勾:勾选是一次发布的口径,不是这个页面的设置。留着勾选状态, * 口径不是页面上的设置,而是「这一次发布」的属性:弹窗每次打开都从默认值起步
* (见 requestPublish 里的 openPublishChoice),发布成功后也复位。留着上一次的选择,
* 换到别的小项再点发布就会把一批预赛成绩当成决赛算分——那是这个功能最贵的错法。 * 换到别的小项再点发布就会把一批预赛成绩当成决赛算分——那是这个功能最贵的错法。
*/ */
const finalPublish = ref(false) const publishChoice = ref<PublishChoice>(defaultPublishChoice())
/** 请求字段那一层要的布尔:true = 按决赛成绩发布。 */
const finalPublish = computed(() => publishChoiceIsFinal(publishChoice.value))
/** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */ /** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */
const settlement = ref<SettlementStatus | null>(null) const settlement = ref<SettlementStatus | null>(null)
const settling = ref(false) const settling = ref(false)
@@ -426,6 +447,37 @@ const nextHeat = computed(() => maxHeat(categoryRows.value) + 1)
/** 表格实际显示的行:组别 + 批次两层筛选;统计、防呆与发布口径都跟着它走。 */ /** 表格实际显示的行:组别 + 批次两层筛选;统计、防呆与发布口径都跟着它走。 */
const visibleRows = computed(() => filterRowsByHeat(categoryRows.value, selectedHeat.value)) const visibleRows = computed(() => filterRowsByHeat(categoryRows.value, selectedHeat.value))
/**
* 搜人关键字(姓名 / 号码布,模糊):起跑人数多的项目(5 公里 / 3 公里)录成绩时
* 找一个名字要找半天,工具栏上这个框就是干这个的。
*
* **纯查看工具**:只过滤表格渲染(displayRows),发布(pendingRows)、进度条分母、
* 勾选与批量操作范围、清空范围、预览算分与名次读的都是 visibleRows(批次口径),
* 一个字都不受影响——现场最怕「筛了一下再点发布,以为只发这几条」。
* 与批次筛选是 AND:两个都生效。**不加防抖**:一屏几十到几百行,纯前端字符串匹配是
* 瞬时的,加了反而显得迟钝。
*/
const searchQuery = ref('')
/** 有没有在搜(全是空白 = 没在搜):过滤、结果提示、空状态都看它。 */
const searching = computed(() => isSearching(searchQuery.value))
/** 表格实际渲染的行:批次筛选之后再按搜索词过滤。只喂给 tbody,不喂给任何口径。 */
const displayRows = computed(() => filterEntryRows(visibleRows.value, searchQuery.value))
/** 结果提示:命中数 / **批次筛选后**的总数(搜索不改变后者)。 */
const searchCountLabel = computed(() =>
searchCountText(displayRows.value.length, visibleRows.value.length),
)
/** 搜不到时的空状态标题(回显关键字,好让人确认自己打的是什么)。 */
const searchEmptyLabel = computed(() => searchEmptyTitle(searchQuery.value))
/** 清除搜索:回到完整名单(输入框的 × 与 Esc 都走这里)。 */
function clearSearch(): void {
searchQuery.value = ''
}
/** 选中的行(只认当前可见的,避免筛选后误改看不见的行)。 */ /** 选中的行(只认当前可见的,避免筛选后误改看不见的行)。 */
const selectedRows = computed(() => const selectedRows = computed(() =>
visibleRows.value.filter((row) => selectedEntryIds.value.includes(row.entryId)), visibleRows.value.filter((row) => selectedEntryIds.value.includes(row.entryId)),
@@ -894,6 +946,13 @@ const MAX_LISTED_ISSUES = 12
const listedIssues = computed(() => [...errors.value, ...warnings.value].slice(0, MAX_LISTED_ISSUES)) const listedIssues = computed(() => [...errors.value, ...warnings.value].slice(0, MAX_LISTED_ISSUES))
const hiddenIssueCount = computed(() => Math.max(0, errors.value.length + warnings.value.length - listedIssues.value.length)) const hiddenIssueCount = computed(() => Math.max(0, errors.value.length + warnings.value.length - listedIssues.value.length))
/**
* 发布确认框的标题:口径在弹窗里选,标题只问「这一次怎么发」。
*
* 不按选中的那一项改标题:选项就在标题下面一行,标题再跟着变反而像已经替人做了决定。
*/
const publishDialogTitle = computed(() => `发布${scopeWord.value}:请选择本次的发布口径`)
/** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */ /** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */
const publishMessage = computed(() => { const publishMessage = computed(() => {
// 通道配置本身就出不来分时,「影响 0 个单位、最大分差 0 分」是句假话(服务端会 409), // 通道配置本身就出不来分时,「影响 0 个单位、最大分差 0 分」是句假话(服务端会 409),
@@ -1073,16 +1132,18 @@ function issueFor(entryId: number): Issue | undefined {
} }
/** /**
* 切换组别:批次筛选与行选择都回到原点。 * 切换组别:批次筛选、行选择与搜索都回到原点。
* *
* 批次是「组别之下」的概念,换组别后旧批次号没有意义;选择留着会让人误以为 * 批次是「组别之下」的概念,换组别后旧批次号没有意义;选择留着会让人误以为
* 还能对看不见的行做批量设置。 * 还能对看不见的行做批量设置;搜索留着则只会得到一张空表。
*/ */
function selectCategory(id: number): void { function selectCategory(id: number): void {
if (selectedCategoryId.value === id) return if (selectedCategoryId.value === id) return
selectedCategoryId.value = id selectedCategoryId.value = id
selectedHeat.value = 'all' selectedHeat.value = 'all'
selectedEntryIds.value = [] selectedEntryIds.value = []
// 搜索与批次同源:换组别后行完全换了一批,留着关键字只会得到一张空表。
clearSearch()
void loadTeamRanks() void loadTeamRanks()
} }
@@ -1116,6 +1177,8 @@ async function loadEvent(id: number): Promise<void> {
selectedCategoryId.value = event.categories?.[0]?.id ?? null selectedCategoryId.value = event.categories?.[0]?.id ?? null
selectedHeat.value = 'all' selectedHeat.value = 'all'
selectedEntryIds.value = [] selectedEntryIds.value = []
// 搜索同样归零:换小项等于换一份名单(与批次筛选、行选择同一个理由)。
clearSearch()
allRows.value = board.entries.map(toRow) allRows.value = board.entries.map(toRow)
// 记下「上次录入的是哪个小项」:下次进录入页要选回它(按 id,改名不影响)。 // 记下「上次录入的是哪个小项」:下次进录入页要选回它(按 id,改名不影响)。
// 只在真的加载成功之后写——加载失败还留着旧项目,不该把记忆改掉。 // 只在真的加载成功之后写——加载失败还留着旧项目,不该把记忆改掉。
@@ -1516,6 +1579,25 @@ function athleteCellTitle(row: EditableRow): string {
: `${head}|点击修改这条报名的队员(个人分与个人奖牌按队员算)` : `${head}|点击修改这条报名的队员(个人分与个人奖牌按队员算)`
} }
/**
* 展开了完整名单的行(按 entryId 记)。
*
* 只放在本机内存里:换小项 / 刷新页面就回到收起态。名单长不长是这一屏当时的观感问题,
* 不是要存下来的设置——存了反而会让下次打开时莫名其妙地有一行是撑开的。
*/
const expandedAthleteIds = ref<Set<number>>(new Set())
function athleteCellExpanded(row: EditableRow): boolean {
return expandedAthleteIds.value.has(row.entryId)
}
function toggleAthleteCell(row: EditableRow): void {
// 换一个新 Set 再赋值:ref 只在赋值时通知依赖,原地 add / delete 不会重渲染。
const next = new Set(expandedAthleteIds.value)
if (!next.delete(row.entryId)) next.add(row.entryId)
expandedAthleteIds.value = next
}
async function openAthleteEditor(row: EditableRow): Promise<void> { async function openAthleteEditor(row: EditableRow): Promise<void> {
athleteEditor.value = { athleteEditor.value = {
entryId: row.entryId, entryId: row.entryId,
@@ -1768,6 +1850,9 @@ async function saveDraft(silent = false): Promise<boolean> {
function requestPublish(): void { function requestPublish(): void {
if (selectedEventId.value === null) return if (selectedEventId.value === null) return
// 弹窗已经开着时不再走一遍「打开弹窗」:那会把操作员刚在弹窗里选好的口径复位成默认。
// Alt+Enter 是本页级的快捷键(弹窗开着也收得到),这一步防的就是它连按两下。
if (publishDialog.value) return
if (data.isMock) { if (data.isMock) {
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。 // 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事') toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
@@ -1789,6 +1874,9 @@ function requestPublish(): void {
toast.error(`有 ${errors.value.length} 处校验未通过`, errors.value[0]?.message ?? null) toast.error(`有 ${errors.value.length} 处校验未通过`, errors.value[0]?.message ?? null)
return return
} }
// 口径在弹窗里选,且**每次都从默认的「只更新名次」起步**:上一次(哪怕是换这个小项之前)
// 选过「按决赛成绩发布」也不沿用,否则会把一批预赛成绩当成决赛算分。
publishChoice.value = openPublishChoice(publishChoice.value)
publishDialog.value = true publishDialog.value = true
} }
@@ -1813,9 +1901,9 @@ async function confirmPublish(): Promise<void> {
rankOnly: rankOnlyOf(finalPublish.value), rankOnly: rankOnlyOf(finalPublish.value),
}) })
publishDialog.value = false publishDialog.value = false
// 勾选是一次发布的口径,不是页面设置:发完就复位成「只更新名次」, // 口径是一次发布的属性,不是页面设置:发完就复位成「只更新名次」,
// 免得换个小项再点发布时把预赛成绩当成决赛算分。 // 免得换个小项再点发布时把预赛成绩当成决赛算分。
finalPublish.value = false publishChoice.value = defaultPublishChoice()
await reloadAfterSettlementChange() await reloadAfterSettlementChange()
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。 // 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
const deferred = settlement.value?.liveOnPublish === false const deferred = settlement.value?.liveOnPublish === false
@@ -2403,6 +2491,11 @@ function onCellKeydown(event: KeyboardEvent, element: HTMLElement): void {
function onPageKeydown(event: KeyboardEvent): void { function onPageKeydown(event: KeyboardEvent): void {
if (!event.ctrlKey && !event.altKey) return if (!event.ctrlKey && !event.altKey) return
if (event.key !== 'Enter') return if (event.key !== 'Enter') return
// 来源是搜索框就忽略:在里面按 Alt+Enter 是想接着找人,不是想发布这一组。
// 只挡搜索框这一个来源——成绩格里的 Alt+Enter 仍是发布(那是录入动作的一部分),
// 既有快捷键的语义一个字都不动。
const target = event.target
if (target instanceof HTMLElement && target.dataset.searchInput !== undefined) return
event.preventDefault() event.preventDefault()
if (saving.value || publishing.value || unpublishing.value) return if (saving.value || publishing.value || unpublishing.value) return
if (event.altKey) requestPublish() if (event.altKey) requestPublish()
@@ -2419,28 +2512,34 @@ function onBeforeUnload(event: BeforeUnloadEvent): void {
/** /**
* Excel 粘贴:两列及以上时,第一列按单位名匹配行,最后一列作为成绩; * Excel 粘贴:两列及以上时,第一列按单位名匹配行,最后一列作为成绩;
* 单列时从当前行往下顺序填充(07 第 2 节第 4 条)。 * 单列时从当前行往下顺序填充(07 第 2 节第 4 条)。
*
* 顺序填充走的是**表格上真正显示的那一份**(displayRows = 批次 + 搜索),不是批次口径的
* visibleRows:粘贴本来就是「照着屏幕上从上往下抄」,搜索把行藏起来之后如果还按批次口径往后数,
* 第二条成绩就会落到看不见的人头上——那是静默改错人,比「少填几行」严重得多。
* 发布 / 进度 / 勾选 / 清空那些口径不受影响,它们读的仍是 visibleRows。
*/ */
function onPaste(event: ClipboardEvent, startEntryId: number): void { function onPaste(event: ClipboardEvent, startEntryId: number): void {
const text = event.clipboardData?.getData('text/plain') ?? '' const text = event.clipboardData?.getData('text/plain') ?? ''
if (text.trim() === '') return if (text.trim() === '') return
// 已发布的行现在是 readonly:浏览器照样会派发 paste 事件(只是不插入文本), // 已发布的行现在是 readonly:浏览器照样会派发 paste 事件(只是不插入文本),
// 放过去的话这一条成绩会被本地悄悄改掉、连确认框都不弹,必须在这里挡住。 // 放过去的话这一条成绩会被本地悄悄改掉、连确认框都不弹,必须在这里挡住。
const start = visibleRows.value.find((row) => row.entryId === startEntryId) const rows = displayRows.value
const start = rows.find((row) => row.entryId === startEntryId)
if (start === undefined || rowLocked(start)) return if (start === undefined || rowLocked(start)) return
event.preventDefault() event.preventDefault()
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '') const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
let cursor = visibleRows.value.findIndex((row) => row.entryId === startEntryId) let cursor = rows.findIndex((row) => row.entryId === startEntryId)
for (const line of lines) { for (const line of lines) {
const columns = line.split('\t').map((column) => column.trim()) const columns = line.split('\t').map((column) => column.trim())
if (cursor < 0 || cursor >= visibleRows.value.length) break if (cursor < 0 || cursor >= rows.length) break
let target = visibleRows.value[cursor] let target = rows[cursor]
if (target === undefined) break if (target === undefined) break
if (columns.length >= 2) { if (columns.length >= 2) {
const key = columns[0] ?? '' const key = columns[0] ?? ''
const matched = visibleRows.value.find( const matched = rows.find(
(row) => row.teamName.includes(key) || (row.teamShort !== '' && key.includes(row.teamShort)), (row) => row.teamName.includes(key) || (row.teamShort !== '' && key.includes(row.teamShort)),
) )
if (matched !== undefined) target = matched if (matched !== undefined) target = matched
@@ -2448,7 +2547,7 @@ function onPaste(event: ClipboardEvent, startEntryId: number): void {
} else { } else {
target.resultText = columns[0] ?? '' target.resultText = columns[0] ?? ''
} }
cursor = visibleRows.value.indexOf(target) + 1 cursor = rows.indexOf(target) + 1
} }
toast.info(`已粘贴 ${lines.length} 行`) toast.info(`已粘贴 ${lines.length} 行`)
} }
@@ -2510,27 +2609,9 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<template #actions> <template #actions>
<span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span> <span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span>
<!-- <!--
发布口径的勾选与标记:只影响「发布」这一个动作。 工具栏上不再有「以决赛成绩发布」的常驻勾选框:口径改到点「发布」之后弹出的那个
默认不勾 = 本次只更新名次(预赛也要排名次,但不计分、不发奖牌、不动榜单); 确认框里选(见下方的发布确认框)。工具栏只留按钮本身,悬停告诉操作员下一步会问什么。
勾上 = 与改动前一样,按决赛成绩算分、发牌、上榜。
勾选状态先于发布按钮出现,让操作员在按下去之前就看清楚这一下会发生什么。
--> -->
<label class="publish-final-toggle" :title="FINAL_PUBLISH_TITLE">
<input
v-model="finalPublish"
type="checkbox"
:disabled="!canWrite || data.isMock || publishing"
:title="FINAL_PUBLISH_TITLE"
/>
<span>{{ FINAL_PUBLISH_LABEL }}</span>
</label>
<span
class="mx-badge publish-mode-badge"
:class="finalPublish ? 'mx-badge-success' : 'mx-badge-warning'"
:title="publishModeHint(finalPublish)"
>
{{ publishModeBadge(finalPublish) }}
</span>
<button <button
class="mx-btn mx-btn-secondary" class="mx-btn mx-btn-secondary"
type="button" type="button"
@@ -2544,7 +2625,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
class="mx-btn mx-btn-primary" class="mx-btn mx-btn-primary"
type="button" type="button"
:disabled="!canWrite || publishing || data.isMock || pendingRows.length === 0" :disabled="!canWrite || publishing || data.isMock || pendingRows.length === 0"
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : publishModeHint(finalPublish)" :title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : PUBLISH_BUTTON_TITLE"
@click="requestPublish" @click="requestPublish"
> >
<MxIcon name="push" :size="15" /> 发布<span v-if="hasPublished && pendingRows.length > 0">本批</span> <MxIcon name="push" :size="15" /> 发布<span v-if="hasPublished && pendingRows.length > 0">本批</span>
@@ -2619,6 +2700,38 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</option> </option>
</select> </select>
</label> </label>
<!--
搜人(姓名 / 号码布,模糊):5 公里 / 3 公里这种大项目的起跑名单一屏几百行,
找一个名字要找半天。输入即时过滤,不加防抖(纯前端字符串匹配是瞬时的)。
**只是查看工具**:发布范围、进度条、勾选与清空范围、预览算分都不跟着变,
所以这一行小字常驻显示,说清它不影响什么——现场最怕「筛了一下再点发布」。
Esc 与右侧的 × 都能清空;框里按 Alt+Enter 不会误触发发布(见 onPageKeydown)。
-->
<div class="entry-search">
<div class="mx-input-wrap entry-search-box" :title="SEARCH_SCOPE_NOTE">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input
v-model="searchQuery"
data-search-input
class="mx-input"
type="text"
placeholder="搜姓名 / 号码布"
@keydown.esc="clearSearch"
/>
<button
v-if="searching"
class="entry-search-clear"
type="button"
title="清除搜索,回到完整名单(在输入框里按 Esc 也可以)"
@click="clearSearch"
>
<MxIcon name="close" :size="13" />
</button>
</div>
<span class="entry-search-hint">
<template v-if="searching">{{ searchCountLabel }};</template>{{ SEARCH_SCOPE_NOTE }}
</span>
</div>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<span class="entry-counts"> <span class="entry-counts">
@@ -2859,13 +2972,13 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<div v-if="hasPublished" class="published-note"> <div v-if="hasPublished" class="published-note">
<MxIcon name="lock" :size="15" /> <MxIcon name="lock" :size="15" />
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录, {{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
录完再点一次「发布本批」即可;勾选「以决赛成绩发布」时名次与积分按已发布成绩整体重算。 录完再点一次「发布本批」即可;发布时在弹窗里选「按决赛成绩发布」,名次与积分按已发布成绩整体重算。
要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。 要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。
</div> </div>
<!-- <!--
本组在榜上的口径(预赛还是决赛):只排名 / 已计分一眼看出来。 本组在榜上的口径(预赛还是决赛):只排名 / 已计分一眼看出来。
判据是「已发布成绩有没有拿到分」,也就是榜面上的事实——系统不记「上次勾了什么」, 判据是「已发布成绩有没有拿到分」,也就是榜面上的事实——系统不记上次选了哪种口径,
而榜单本来就只认流水。它同时点破了只更新名次发布后的「名次变了、分还是旧的」。 而榜单本来就只认流水。它同时点破了只更新名次发布后的「名次变了、分还是旧的」。
--> -->
<div <div
@@ -2888,7 +3001,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
type="checkbox" type="checkbox"
:checked="allVisibleSelected" :checked="allVisibleSelected"
:disabled="visibleRows.length === 0" :disabled="visibleRows.length === 0"
:title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行)`" :title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行${searching ? ';搜索不改变勾选与批量操作的范围,这里仍是全部行' : ''})`"
@change="toggleAllVisible" @change="toggleAllVisible"
/> />
</th> </th>
@@ -2914,7 +3027,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="row in visibleRows" v-for="row in displayRows"
:key="row.entryId" :key="row.entryId"
:class="{ :class="{
'is-dirty': rowDirty(row), 'is-dirty': rowDirty(row),
@@ -2983,8 +3096,30 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<span v-if="row.squad !== ''" class="joint-member">队伍 {{ row.squad }}</span> <span v-if="row.squad !== ''" class="joint-member">队伍 {{ row.squad }}</span>
</span> </span>
</td> </td>
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 --> <!--
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span> 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。
多人团体 / 接力的名单很长,收起时限宽 + 省略号(见 .athlete-cell-text),
别让这一列把成绩列一起挤出屏;展开按行记在本机内存里,换小项 / 刷新即回到收起态。
搜索时把命中的那几个字标出来(只加底色,文字仍是原文,切法见 entrySearch.ts):
一屏几百行里「找到的是不是这个人」要一眼确认,不必再去看号码布列。
没在搜索时走上面那一条分支,渲染与搜索功能加入前逐字相同。
-->
<td>
<span
class="athlete-cell-text"
:class="athleteCellClass(athleteCellExpanded(row))"
><span v-if="!searching" class="athlete-cell-name">{{ athleteCellName(row) }}</span><span v-else class="athlete-cell-name"><template v-for="(segment, index) in highlightSegments(athleteCellName(row), searchQuery)" :key="index"><mark v-if="segment.hit" class="entry-hit">{{ segment.text }}</mark><template v-else>{{ segment.text }}</template></template></span></span> <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span>
<!-- 只有长到会被截断的名单才给这个按钮:个人项目的姓名永远不该出现「展开」。 -->
<button
v-if="shouldOfferAthleteToggle(athleteCellName(row))"
class="mx-btn mx-btn-ghost mx-btn-sm athlete-toggle"
type="button"
:title="athleteToggleTitle(athleteCellExpanded(row))"
@click.stop="toggleAthleteCell(row)"
>
{{ athleteToggleLabel(athleteCellExpanded(row)) }}
</button>
<!-- <!--
队员(devdocs/20):只有纯团体报名才有这个按钮——个人报名的身份就是那一个运动员。 队员(devdocs/20):只有纯团体报名才有这个按钮——个人报名的身份就是那一个运动员。
老数据(没有队员关联)的文案一个字符都没变,这里只是多了一个改的入口。 老数据(没有队员关联)的文案一个字符都没变,这里只是多了一个改的入口。
@@ -3132,6 +3267,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<span v-if="rowActionsOf(row).length === 0" class="muted">—</span> <span v-if="rowActionsOf(row).length === 0" class="muted">—</span>
</td> </td>
</tr> </tr>
<!--
搜不到:不能只留一张空表——现场会以为这批人根本没报名。
说清「是搜索没匹配上」(回显关键字)+ 一个一键清除的按钮。
-->
<tr v-if="!loading && searching && visibleRows.length > 0 && displayRows.length === 0">
<td colspan="12">
<div class="mx-empty">
<div class="mx-empty-title">{{ searchEmptyLabel }}</div>
<div class="mx-empty-desc">{{ SEARCH_EMPTY_HINT }}</div>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="clearSearch">
<MxIcon name="close" :size="14" /> 清除搜索
</button>
</div>
</td>
</tr>
<tr v-if="!loading && visibleRows.length === 0"> <tr v-if="!loading && visibleRows.length === 0">
<td colspan="12"> <td colspan="12">
<div class="mx-empty"> <div class="mx-empty">
@@ -3170,10 +3320,10 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</div> </div>
<template v-else> <template v-else>
<!-- <!--
预览永远按「计分发布」算分:不勾「以决赛成绩发布」时这些分差这次不会发生, 预览永远按「计分发布」算分:发布弹窗里选了「只更新名次」时这些分差这次不会发生。
卡片里必须说破,否则操作员会以为按了发布就涨了这些分。 口径要等点发布才选,所以这句常驻显示,而不是跟着某个勾选状态出现 / 消失。
--> -->
<p v-if="!finalPublish" class="preview-hint preview-rank-only-hint"> <p class="preview-hint preview-rank-only-hint">
{{ PREVIEW_RANK_ONLY_HINT }} {{ PREVIEW_RANK_ONLY_HINT }}
</p> </p>
<p class="preview-hint"> <p class="preview-hint">
@@ -3320,17 +3470,47 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</section> </section>
</div> </div>
<!--
发布确认框,也是本页唯一的发布入口(工具栏按钮 / Alt+Enter 都走 requestPublish):
口径就在这一个弹窗里问完 —— 上面那组二选一挂在 ConfirmDialog 的默认插槽里,
不再另开第二个弹窗,也不在工具栏上留常驻勾选框。
-->
<ConfirmDialog <ConfirmDialog
:open="publishDialog" :open="publishDialog"
tone="warning" tone="warning"
:title="finalPublish ? '确认按决赛成绩发布该组?' : '确认发布该组(只更新名次)?'" :title="publishDialogTitle"
:message="publishMessage" :message="publishMessage"
:details="warnings.map((issue) => issue.message)" :details="warnings.map((issue) => issue.message)"
confirm-text="确认发布" confirm-text="确认发布"
:pending="publishing" :pending="publishing"
@confirm="confirmPublish" @confirm="confirmPublish"
@cancel="publishDialog = false" @cancel="publishDialog = false"
/> >
<div class="publish-choice">
<span class="publish-choice-head">本次怎么发?</span>
<label
v-for="option in PUBLISH_OPTIONS"
:key="option.value"
class="publish-choice-option"
:class="{ active: publishChoice === option.value }"
>
<input
v-model="publishChoice"
type="radio"
name="publish-choice"
:value="option.value"
:disabled="publishing"
/>
<span class="publish-choice-text">
<span class="publish-choice-label">
{{ option.title }}
<span v-if="option.badge !== ''" class="mx-badge mx-badge-warning">{{ option.badge }}</span>
</span>
<span class="publish-choice-hint">{{ option.hint }}</span>
</span>
</label>
</div>
</ConfirmDialog>
<!-- <!--
补报名:外壳照页面里既有的写法(RecordsPage 的新增纪录弹窗)自己套一层, 补报名:外壳照页面里既有的写法(RecordsPage 的新增纪录弹窗)自己套一层,
@@ -3806,6 +3986,56 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
width: auto; width: auto;
min-width: 104px; min-width: 104px;
} }
/*
* 搜人框:紧挨批次下拉,观感与它一档(同一套 mx-input-wrap / mx-input),不抢右上角按钮的视线。
* 只是查看工具,所以旁边那行小字常驻显示(见 SEARCH_SCOPE_NOTE)。
*/
.entry-search {
display: inline-flex;
align-items: center;
gap: 8px;
}
.entry-search-box {
position: relative;
width: 220px;
}
/* 给右侧的清除按钮留出位置,否则输入的字会钻到 × 底下。 */
.entry-search-box .mx-input {
padding-right: 22px;
}
.entry-search-clear {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: var(--mx-r-xs);
background: transparent;
color: var(--mx-t3);
cursor: pointer;
}
.entry-search-clear:hover {
color: var(--mx-text);
background: var(--mx-divider);
}
.entry-search-hint {
font-size: 12px;
color: var(--mx-t3);
white-space: nowrap;
}
/* 命中片段:用本页既有的强调色,只让人认出是哪几个字,不与成绩格的错误态抢注意力。 */
.entry-hit {
background: var(--mx-primary-glow);
color: var(--mx-primary);
border-radius: var(--mx-r-xs);
padding: 0 1px;
}
/* 批量设置批次的临时操作条:只有勾了行才出现。 */ /* 批量设置批次的临时操作条:只有勾了行才出现。 */
.heat-bar { .heat-bar {
display: flex; display: flex;
@@ -3959,24 +4189,60 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
color: var(--mx-green); color: var(--mx-green);
font-size: 13px; font-size: 13px;
} }
/* 发布口径勾选:贴着发布按钮,未勾(只更新名次)用警示色,勾上再用主色。 */ /*
.publish-final-toggle { 发布弹窗里的口径二选一:挂在 ConfirmDialog 的默认插槽上(作用域样式跟着插槽内容走)。
display: inline-flex; 与「平均值口径」那一组同一种观感:一个选项一行,标题 + 说明,选中项用主色。
align-items: center; */
gap: 6px; .publish-choice {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding: 10px 12px;
border-radius: var(--mx-r-sm);
border: 1px solid var(--mx-primary);
background: var(--mx-primary-glow);
}
.publish-choice-head {
font-size: 13px;
font-weight: 600;
color: var(--mx-primary);
}
.publish-choice-option {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px; font-size: 13px;
color: var(--mx-t2); color: var(--mx-t2);
cursor: pointer; cursor: pointer;
user-select: none;
} }
.publish-final-toggle input { .publish-choice-option input {
margin-top: 2px;
flex: none;
cursor: pointer; cursor: pointer;
} }
.publish-final-toggle input:disabled { .publish-choice-option input:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
.publish-mode-badge { .publish-choice-text {
white-space: nowrap; display: flex;
flex-direction: column;
gap: 2px;
}
.publish-choice-label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--mx-t1);
}
.publish-choice-option.active .publish-choice-label {
color: var(--mx-primary);
font-weight: 600;
}
.publish-choice-hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
} }
/* /*
本组在榜上的口径(预赛 / 决赛):只排名用中性色(常态),已计分用绿色(有价值的信息), 本组在榜上的口径(预赛 / 决赛):只排名用中性色(常态),已计分用绿色(有价值的信息),
@@ -4512,6 +4778,52 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
font-size: 11px; font-size: 11px;
color: var(--mx-t3); color: var(--mx-t3);
} }
/*
* 「运动员 / 队」格里的姓名文本:收起时限宽 + 省略号。
*
* 限宽取 14em(13px 正文字号下 182px):这一格是 nowrap,不限宽的话多人团体 / 接力的
* 名单会把整列撑开,把右边的成绩格挤出屏幕——八个人(约 110 个显示宽度)实测就有 698px。
* 182px 装得下两个人带号码布的名单(`张伟(A001)、李强(B002)` 26 个显示宽度,实测 170px),
* 加上号码布、小按钮与单元格内边距也就 290px 上下:1440px 的一屏上
* 单位列、姓名列、成绩列(.result-cell 的 min-width: 140px)能同时看得见。
* 14em = 28 个显示宽度(12 个汉字 = 156px),大于 ATHLETE_TOGGLE_THRESHOLD(24 个显示宽度),
* 保证「不出现展开按钮的文本一定没有被截断」。
*
* 限宽在外层、省略号在内层:带 overflow:hidden 的 inline-block 基线会变成下边框,
* 姓名会比旁边的号码布、「队员」按钮翘高半行;把 overflow 挪进内层后外层照常按基线对齐,
* 三种字号仍落在同一条基线上。
*/
.athlete-cell-text {
display: inline-block;
max-width: 14em;
}
.athlete-cell-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
* 展开态:放开限宽、允许换行。
* 换行优先落在「、」上(keep-all 不拆汉字词),一个「姓名(号码布)」本身太长时
* 才用 anywhere 从中间断开——宁可断字,也不能溢出这一格去挤成绩列。
*
* 展开时改成 block:这一格是 nowrap,姓名块若和后面的号码布、「队员」按钮同处一行,
* 它把整行占满后号码布与按钮就会被顶出格子、压在成绩列上(实测过);
* 变成 block 让号码布与按钮落到下面一行,格子只长高、不会横向溢出。
*/
.athlete-cell-text.is-expanded {
display: block;
max-width: none;
}
.athlete-cell-text.is-expanded .athlete-cell-name {
white-space: normal;
word-break: keep-all;
/* anywhere 同时能把这一格的最小宽度压小,表格挤的时候先让这一列换行;老引擎不认
anywhere,补一个 break-word 兜底——展开态没有省略号,被 overflow:hidden 裁掉就再也看不到了。 */
overflow-wrap: break-word;
overflow-wrap: anywhere;
}
/* 「队员」入口:紧跟在队名单后面,弱化成一个小按钮,不抢姓名与成绩的位置。 */ /* 「队员」入口:紧跟在队名单后面,弱化成一个小按钮,不抢姓名与成绩的位置。 */
.athlete-edit { .athlete-edit {
margin-left: 6px; margin-left: 6px;
@@ -4519,6 +4831,13 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
font-size: 11px; font-size: 11px;
line-height: 18px; line-height: 18px;
} }
/* 「展开 / 收起」入口:与「队员」同一套观感(同一组 mx-btn-ghost mx-btn-sm 覆盖),排在它前面。 */
.athlete-toggle {
margin-left: 6px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
}
/* 队员候选列表:与补报名里的队员勾选同一套观感(那套是 EntryPicker 的 scoped 样式, /* 队员候选列表:与补报名里的队员勾选同一套观感(那套是 EntryPicker 的 scoped 样式,
作用不到这里,所以在这边按同一份排版自己写一份)。 */ 作用不到这里,所以在这边按同一份排版自己写一份)。 */
.athlete-choice-list { .athlete-choice-list {
@@ -0,0 +1,66 @@
import type { DisplayInstance } from '@/shared/api'
/**
* 「在线大屏」列表里一台设备怎么显示。
*
* 抽成纯函数是因为组件跑不进测试(仓库里没有 jsdom):名称优先级、悬浮提示里到底有哪几行、
* 别名请求体怎么组装,这三件事一旦写错,现场看到的就是「列表里全是 display-a1b2c3d4」
* 或者「清别名把名字清成了空格」,而 vue-tsc 一个字都不会说。
*/
/** 空串按「没有」处理:服务端可空字段与老服务端缺字段都长这样。 */
function nonEmpty(value: string | undefined | null): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
/**
* 列表主行显示的名字,优先级:手动别名 → 自动设备标签 → 实例 id。
*
* 别名是操作员自己起的(「二号楼平板」),永远压过自动标签;自动标签是浏览器报上来的
* 设备信息,比一串 `display-a1b2c3d4` 强;两者都没有(老客户端 / 老服务端)时退回 id,
* 至少排障时还认得出是哪一条。
*/
export function instanceDisplayName(instance: DisplayInstance): string {
return nonEmpty(instance.alias) ?? nonEmpty(instance.device) ?? instance.id
}
/** 主行已经不是 id 时,id 降级成一行小字;名字本身就是 id 时不重复渲染一遍。 */
export function instanceIdNote(instance: DisplayInstance): string | null {
return instanceDisplayName(instance) === instance.id ? null : instance.id
}
/**
* 悬浮提示:排障要用的那几行,缺的整行不出现。
*
* 设备标签在最上面(被截断过也能一眼看出是哪台),紧接着是原始 UA —— 想知道「到底是什么机型」
* 只能看它;来源 IP 与屏号用来对上「哪台机器」;id 是切页指令点名的目标值。
*/
export function instanceTooltip(instance: DisplayInstance): string {
const lines: Array<[string, string | null]> = [
['设备', nonEmpty(instance.device)],
['原始 UA', nonEmpty(instance.deviceRaw)],
['实例 id', instance.id],
['来源', nonEmpty(instance.remoteAddr)],
['屏号', nonEmpty(instance.screen)],
]
return lines
.filter((line): line is [string, string] => line[1] !== null)
.map(([label, value]) => `${label}:${value}`)
.join('\n')
}
/** 界面提示:改名与清除分别做什么(按钮 title 与就地说明共用一句)。 */
export const ALIAS_RENAME_HINT = '给这台设备起个认得出的名字(例如「二号楼平板」),只对这台设备生效'
export const ALIAS_CLEAR_HINT = '清除手动名字,回到浏览器报上来的设备标签'
/**
* 别名请求体。
*
* 服务端口径:空串或只空白 = 清除别名、回到自动标签。这里统一 trim —— 现场从聊天记录里
* 粘贴过来的名字几乎必然带上首尾空格,不 trim 就会存成一个「看着一样、实际多两个空格」的名字。
*/
export function aliasPayload(input: string): { alias: string } {
return { alias: input.trim() }
}
+96 -17
View File
@@ -1,36 +1,109 @@
import { formatPoints } from '@/shared' import { formatPoints } from '@/shared'
/** /**
* 「以决赛成绩发布」勾选项的口径与文案。 * 发布口径(只更新名次 / 按决赛成绩发布)的口径与文案。
* *
* 现场只有最后一轮(决赛)的成绩算分,所以发布入口多了一个勾选: * 现场只有最后一轮(决赛)的成绩算分,所以发布入口多了一问:
* 不勾(默认)= 本次只更新名次(预赛也要排名次),不计分、不发奖牌、榜单分数不变; * 只更新名次(默认)= 本次只更新名次(预赛也要排名次),不计分、不发奖牌、榜单分数不变;
* 勾上 = 与改动前完全一样:算分、发牌、上榜。 * 按决赛成绩发布 = 与改动前完全一样:算分、发牌、上榜。
* *
* 单独成一个模块(而不是散在页面里),是为了让「勾选状态 → 请求字段 → 各处文案」这条链子 * 这一问**在录入页点「发布」之后弹出的确认框里问**(原来挂在工具栏上的常驻勾选框已撤掉):
* 口径只在「这一次发布」上有意义,而弹窗本身就带着上下文(发的是哪一组、会影响什么)。
* 默认值仍是「只更新名次」,并且**每次打开弹窗都从默认值起步**,绝不沿用上一次的选择——
* 沿用会把一批预赛成绩当成决赛算分,那是这个功能最贵的错法。
*
* 单独成一个模块(而不是散在页面里),是为了让「弹窗选项 → 请求字段 → 各处文案」这条链子
* 能脱离组件单测:文案说反了现场就会按错的口径发布,这是本功能最容易出错的地方。 * 能脱离组件单测:文案说反了现场就会按错的口径发布,这是本功能最容易出错的地方。
*/ */
/** 勾选框文案:一句话说清勾上之后会发生什么。 */ /**
* 审核页(成绩审核 → 发布整组)仍然是常驻勾选框,这两个常量归它用。
* 录入页已改成弹窗里二选一,不再引用它们。
*/
export const FINAL_PUBLISH_LABEL = '以决赛成绩发布(勾选才计分发牌)' export const FINAL_PUBLISH_LABEL = '以决赛成绩发布(勾选才计分发牌)'
/** 勾选框的悬停说明:两种口径各会发生什么。 */
export const FINAL_PUBLISH_TITLE = export const FINAL_PUBLISH_TITLE =
'勾上 = 本次按决赛成绩发布:重算名次并计分、发奖牌、榜单跟着更新;不勾(默认)= 本次只更新名次,不计分、不发奖牌,榜单分数不变。' '勾上 = 本次按决赛成绩发布:重算名次并计分、发奖牌、榜单跟着更新;不勾(默认)= 本次只更新名次,不计分、不发奖牌,榜单分数不变。'
/** 请求字段:不勾就是「只更新名次」。 */ /** 请求字段:没选「按决赛成绩发布」就是「只更新名次」。 */
export function rankOnlyOf(finalPublish: boolean): boolean { export function rankOnlyOf(finalPublish: boolean): boolean {
return !finalPublish return !finalPublish
} }
/** 勾选框旁的常驻提示:未勾时必须说清「这一下不计分」。 */ /**
* 发布弹窗里选中的口径。
*
* 用字符串而不是布尔:布尔叫 `finalPublish` 时,「false / true 各代表什么」在调用处看不出来,
* 而这种功能恰恰错不起;选项值直接就是标签页上印着的那两个词。
*/
export type PublishChoice = 'rank-only' | 'final'
/** 弹窗默认选中的口径:只更新名次(不主动让操作员把预赛当决赛算分)。 */
export function defaultPublishChoice(): PublishChoice {
return 'rank-only'
}
/**
* 弹窗每次打开时的初始选择:**永远**是默认的「只更新名次」。
*
* 参数故意收下上一次选过什么(换小项前选过「按决赛成绩发布」也算):签名上的 `_previous`
* 就是「这里不读它」这件事的证据 —— 上一次的选择不能替这一次背书。
*/
export function openPublishChoice(_previous: PublishChoice | null): PublishChoice {
return defaultPublishChoice()
}
/** 选项值 → 请求字段那一层要的布尔(true = 按决赛成绩发布)。 */
export function publishChoiceIsFinal(choice: PublishChoice): boolean {
return choice === 'final'
}
/** 弹窗里一个互斥选项的标题与说明。 */
export interface PublishOption {
value: PublishChoice
/** 单选按钮旁那句。 */
title: string
/** 选项下面的说明,直接复用 publishModeHint 的口径,不另写一套说法。 */
hint: string
/** 默认选中项的标记;不是默认项时为空串。 */
badge: string
}
/** 「只更新名次」这一项的标题。 */
export const RANK_ONLY_OPTION_TITLE = '只更新名次(不计分、不发奖牌)'
/** 「按决赛成绩发布」这一项的标题。 */
export const FINAL_OPTION_TITLE = '按决赛成绩发布(重算名次并计分、发奖牌、榜单跟着更新)'
/** 两种口径各会发生什么:录入页弹窗的选项说明、审核页的常驻提示都用它(不进请求)。 */
export function publishModeHint(finalPublish: boolean): string { export function publishModeHint(finalPublish: boolean): string {
return finalPublish return finalPublish
? '本次按决赛成绩发布:算分、发奖牌、榜单跟着更新。' ? '本次按决赛成绩发布:算分、发奖牌、榜单跟着更新。'
: '本次只更新名次:不计分、不发奖牌,榜单分数不变。' : '本次只更新名次:不计分、不发奖牌,榜单分数不变。'
} }
/** 勾选框旁的短标记(徽标文案),两种口径一眼可辨。 */ /**
* 弹窗里的两个互斥选项:顺序固定为「默认项在前」。
*
* 说明文案取自 publishModeHint(本模块唯一一处口径说明),避免选项说明与常驻提示各说一套。
*/
export const PUBLISH_OPTIONS: readonly PublishOption[] = [
{
value: 'rank-only',
title: RANK_ONLY_OPTION_TITLE,
hint: publishModeHint(false),
badge: '默认',
},
{
value: 'final',
title: FINAL_OPTION_TITLE,
hint: publishModeHint(true),
badge: '',
},
]
/** 工具栏「发布」按钮的悬停说明:口径改到点下去之后的弹窗里选。 */
export const PUBLISH_BUTTON_TITLE = '点「发布」后弹出窗口选择本次口径:只更新名次(默认)/ 按决赛成绩发布'
/** 口径的短标记(徽标文案),两种口径一眼可辨;审核页的常驻徽标用它。 */
export function publishModeBadge(finalPublish: boolean): string { export function publishModeBadge(finalPublish: boolean): string {
return finalPublish ? '计分发布(算分发牌)' : '只更新名次(不计分)' return finalPublish ? '计分发布(算分发牌)' : '只更新名次(不计分)'
} }
@@ -47,14 +120,20 @@ export function publishModeConfirm(finalPublish: boolean, pointsOnBoard: number)
if (finalPublish) { if (finalPublish) {
return '本次按决赛成绩发布:重算名次并计分发奖牌,榜单立刻跟着变。' return '本次按决赛成绩发布:重算名次并计分发奖牌,榜单立刻跟着变。'
} }
const base = '本次只更新名次:不计分、不发奖牌,榜单分数不变(录入页预览里的分差,是勾选「以决赛成绩发布」时的效果)。' const base =
'本次只更新名次:不计分、不发奖牌,榜单分数不变(录入页预览里的分差,是选「按决赛成绩发布」时的效果)。'
if (pointsOnBoard <= 0) return base if (pointsOnBoard <= 0) return base
return `${base}注意:本组已有 ${formatPoints(pointsOnBoard)} 分在榜(上次按决赛发布),名次会按这次成绩更新,但那份积分与奖牌会原样保留、不随名次变动。` return `${base}注意:本组已有 ${formatPoints(pointsOnBoard)} 分在榜(上次按决赛发布),名次会按这次成绩更新,但那份积分与奖牌会原样保留、不随名次变动。`
} }
/** 「实时积分预览」面板在只更新名次时的口径提示(那里的分差这次不会发生)。 */ /**
* 「实时积分预览」面板的口径提示(选「只更新名次」时那里的分差不会发生)。
*
* 预览是在**发布之前**看的,此刻还没有人做过选择,所以这句话不能写「当前是只更新名次」,
* 只能写成「发布时在弹窗里选哪一项会怎样」。
*/
export const PREVIEW_RANK_ONLY_HINT = export const PREVIEW_RANK_ONLY_HINT =
'当前是「只更新名次」:这次发布会按上面的名次落库,但不计分——下列分差是勾选「以决赛成绩发布」时的效果。' '发布时在弹出的窗口里选「只更新名次」(默认)时,这次只按上面的名次落库、不计分——下列分差是选「按决赛成绩发布」时的效果。'
/** 只看已发布成绩在榜上的分(草稿还没结算,不算)。 */ /** 只看已发布成绩在榜上的分(草稿还没结算,不算)。 */
export function publishedPointsOnBoard(rows: readonly { resultState: string | null; points: number | null }[]): number { export function publishedPointsOnBoard(rows: readonly { resultState: string | null; points: number | null }[]): number {
@@ -71,13 +150,13 @@ export function publishedPointsOnBoard(rows: readonly { resultState: string | nu
* 本组在榜上的口径(预赛 / 决赛),给录入页那句常驻说明用。 * 本组在榜上的口径(预赛 / 决赛),给录入页那句常驻说明用。
* *
* 一条都没发布过时返回 null:没有在榜成绩就没有口径可谈,别多一行噪声。 * 一条都没发布过时返回 null:没有在榜成绩就没有口径可谈,别多一行噪声。
* 判据是「已发布成绩有没有从流水拿到分」,而不是「上次勾没勾」——系统不记「上次勾了什么」, * 判据是「已发布成绩有没有从流水拿到分」,而不是「上次选了哪一种」——系统不记上次的选择,
* 而榜单上的分与牌本来就只认流水,这句话描述的正是榜面事实。 * 而榜单上的分与牌本来就只认流水,这句话描述的正是榜面事实。
*/ */
export function boardModeNote(publishedCount: number, pointsOnBoard: number): string | null { export function boardModeNote(publishedCount: number, pointsOnBoard: number): string | null {
if (publishedCount <= 0) return null if (publishedCount <= 0) return null
if (pointsOnBoard <= 0) { if (pointsOnBoard <= 0) {
return '本组在榜上只有名次(上次是按「只更新名次」发布的,不计分):要算分、发奖牌,请勾选「以决赛成绩发布」再发布一次。' return '本组在榜上只有名次(上次是按「只更新名次」发布的,不计分):要算分、发奖牌,请在发布弹窗里选「按决赛成绩发布」再发布一次。'
} }
return `本组在榜上是已计分的成绩(已有 ${formatPoints(pointsOnBoard)} 分来自上一次按决赛发布):不勾「以决赛成绩发布」再发一次只会更新名次,这些积分与奖牌不会被改动。` return `本组在榜上是已计分的成绩(已有 ${formatPoints(pointsOnBoard)} 分来自上一次按决赛发布):发布时选「只更新名次」再发一次只会更新名次,这些积分与奖牌不会被改动。`
} }
+79
View File
@@ -4,6 +4,10 @@
* 抽出来的原因:号码布原本只在左边单独一列,录成绩时眼睛要在姓名与号码之间来回跳, * 抽出来的原因:号码布原本只在左边单独一列,录成绩时眼睛要在姓名与号码之间来回跳,
* 所以姓名紧后面要再显示一次号码布;而「姓名怎么回落」与「号码布为空时不占位」 * 所以姓名紧后面要再显示一次号码布;而「姓名怎么回落」与「号码布为空时不占位」
* 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。 * 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。
*
* 另一件事是多人团体 / 接力的名单(`张伟(A001)、李强(A002)、…`)会很长,
* 收起时要限宽截断、长了才给「展开 / 收起」按钮;判据同样必须是纯函数,
* 不然「什么时候该出现按钮」就只能靠肉眼在页面上试。
*/ */
import { entryRosterText } from '@/shared' import { entryRosterText } from '@/shared'
@@ -39,3 +43,78 @@ export function athleteCellName(row: EntryNameLike): string {
export function inlineBibLabel(bib: string | null | undefined): string { export function inlineBibLabel(bib: string | null | undefined): string {
return bib === null || bib === undefined ? '' : bib.trim() return bib === null || bib === undefined ? '' : bib.trim()
} }
/**
* 姓名文本超过这个**显示宽度**才给「展开 / 收起」按钮。
*
* 显示宽度按半角字符算 1、CJK 与全角算 2(见 athleteTextWidth),所以 24 = 12 个汉字。
* 依据:名单里一个人是「姓名(号码布)」约 12 个显示宽度(两字姓名 + 全角括号 + 4 位号码布),
* 两人约 26、四人约 54、八人约 110。取 24 的效果是:个人项目的姓名(最长也就是
* 少数民族姓名那种,约 15)永远不出按钮,三人以上的名单(约 40 起)一定出;
* 两人(26)刚过线也会出——这一点余量是有意的:多一个按了看不出变化的按钮只是小事,
* 「文字被截断了却没有按钮可点」会让人再也看不到完整名单。
*
* 这个值还必须 ≤ CSS 里 .athlete-cell-text 的限宽(14em = 28 个显示宽度),
* 否则不到阈值的文本会被截断又没有按钮可点——测试里钉了这条关系。
*/
export const ATHLETE_TOGGLE_THRESHOLD = 24
/**
* 文本的显示宽度:CJK / 全角字符算 2,其余算 1。
*
* 不用 text.length 是因为「8 个汉字」与「16 个半角字母」在表格里占的宽度相当,
* 按字符个数判会让前者不出现按钮、后者出现,同一批数据看起来自相矛盾。
* 这是粗略口径(比例字体下半角字符宽窄不一),够用来判「要不要给按钮」。
*/
export function athleteTextWidth(text: string): number {
let width = 0
// for...of 按码点迭代:代理对(emoji 等)算一个字符,不会被拆成两个半角。
for (const ch of text) {
width += isWideChar(ch) ? 2 : 1
}
return width
}
/** 是否全角 / CJK:按东亚宽度表取常用区间,够覆盖姓名、号码布、中文标点。 */
function isWideChar(ch: string): boolean {
const code = ch.codePointAt(0) ?? 0
if (code > 0xffff) return true
return (
(code >= 0x1100 && code <= 0x115f) ||
code === 0x2329 ||
code === 0x232a ||
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
(code >= 0xac00 && code <= 0xd7a3) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0xfe30 && code <= 0xfe6f) ||
(code >= 0xff00 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6)
)
}
/**
* 这一行的姓名是否长到需要「展开 / 收起」:**严格大于**阈值才算(等于不给按钮)。
*
* 边界取「不含等于」是为了让阈值本身也是一个「不用给按钮」的合法长度——
* 24 个显示宽度(12 个汉字)在 14em 的限宽里放得下,不给按钮也不会被截断。
*/
export function shouldOfferAthleteToggle(text: string): boolean {
return athleteTextWidth(text) > ATHLETE_TOGGLE_THRESHOLD
}
/** 展开 / 收起按钮上的文案。 */
export function athleteToggleLabel(expanded: boolean): string {
return expanded ? '收起' : '展开'
}
/** 展开 / 收起按钮的悬停提示:把点了会发生什么说清楚(这一格还有个「队员」按钮,别点错)。 */
export function athleteToggleTitle(expanded: boolean): string {
return expanded
? '收起完整名单,恢复成一行(收起时限宽,不会挤走成绩列)'
: '展开完整名单(收起时超长部分用省略号省略)'
}
/** 姓名格的 class 串:展开态加 `is-expanded`(放开限宽、允许换行)。 */
export function athleteCellClass(expanded: boolean): string {
return expanded ? 'is-expanded' : ''
}
+116
View File
@@ -0,0 +1,116 @@
/**
* 录入表格的「搜人」纯函数:一行是否命中关键字、结果提示与空状态文案、命中片段切分。
*
* 为什么要有这一层:起跑人数多的项目(5 公里 / 3 公里)一屏几十到几百行,录成绩时
* 找一个名字要找半天。工具栏上那个搜索框就是干这个的。
*
* **匹配口径不在这里**:姓名 / 号码布(含「007 搜 7」的数字口径)全部复用
* components/baseData/entrySelection.ts 里那一份(athleteMatchesKeyword / bibMatchesKeyword),
* 与补报名的选人搜索框同源,不许再抄第三份。
*
* 过滤器只影响表格渲染(**纯查看工具**):发布范围、进度条分母、勾选范围、清空范围与
* 预览算分读的都是批次口径的 visibleRows,与搜索无关(见 EntryPage.vue 里两个列表的分工)。
*/
import { athleteMatchesKeyword, foldSearchText, foldText } from '../components/baseData/entrySelection'
/** 表格里一行的身份字段:个人行看 athleteName,团体 / 接力行看队员名单与队员关联。 */
export interface EntrySearchRow {
athleteName: string
bib?: string | null
/** 团体 / 接力行的**文字名单**(导入表的 member_names);个人行为 null。 */
memberNames?: string[] | null
/** 团体 / 接力行的**队员关联**(有档案的人,带号码布);没有关联时为空数组。 */
athletes?: readonly { name: string; bib?: string | null }[] | null
}
/** 有没有在搜(全是空白 = 没在搜):过滤、结果提示、空状态都看它。 */
export function isSearching(keyword: string): boolean {
return foldSearchText(keyword) !== ''
}
/**
* 一行是否命中:姓名(个人行)/ 队员名单 / 队员关联里的姓名与号码布,任一命中即算。
*
* 团体行的身份就是那份名单——一行挂着一个单位与一支队,只搜 athleteName(团体行为空)
* 等于「接力队里的人一个都搜不到」,而现场找人正是按队员姓名找。
*
* 关键字全为空白 = 没在搜,返回 true(不能把表格筛空)。
*/
export function entryRowMatchesKeyword(row: EntrySearchRow, keyword: string): boolean {
if (!isSearching(keyword)) return true
if (athleteMatchesKeyword({ name: row.athleteName, bib: row.bib }, keyword)) return true
for (const name of row.memberNames ?? []) {
if (athleteMatchesKeyword({ name }, keyword)) return true
}
for (const athlete of row.athletes ?? []) {
if (athleteMatchesKeyword({ name: athlete.name, bib: athlete.bib }, keyword)) return true
}
return false
}
/** 过滤出显示的行(顺序不变,返回新数组,不改动入参)。空关键字 = 原样返回全部。 */
export function filterEntryRows<T extends EntrySearchRow>(rows: readonly T[], keyword: string): T[] {
if (!isSearching(keyword)) return [...rows]
return rows.filter((row) => entryRowMatchesKeyword(row, keyword))
}
/**
* 搜索框旁边那行小字。
*
* 必须常驻可见:现场最怕的就是「筛了一下再点发布,以为只发这几条」。搜索是查看工具,
* 发布 / 进度 / 勾选 / 清空一律仍按批次口径——这句话要写在按钮旁边,而不是藏在提示气泡里。
*/
export const SEARCH_SCOPE_NOTE = '搜索只影响表格显示,不影响发布范围与进度'
/** 结果提示:找到 N 条 / 共 M 条(M 是**批次筛选后**的行数,搜索不改变它)。 */
export function searchCountText(matched: number, total: number): string {
return `找到 ${matched} 条 / 共 ${total} 条`
}
/** 搜不到时的空状态标题;关键字去首尾空白后回显原文,好让人确认自己打的是什么。 */
export function searchEmptyTitle(keyword: string): string {
return `没有匹配「${keyword.trim()}」的运动员`
}
/** 空状态那句提示:把「可以只搜一部分」这件事说清,别让人以为非得打全名。 */
export const SEARCH_EMPTY_HINT = '可以只输入姓名里的一两个字,或号码布的一部分(号码布 007 搜 7 也能找到)'
/** 命中片段的切法:`hit` 为 true 的一段在界面上高亮。 */
export interface SearchSegment {
text: string
hit: boolean
}
/**
* 把文本按关键字切成「命中 / 未命中」交替的片段,界面据此高亮。
*
* 用与匹配**同一个折叠口径**找位置(全角空格、全角 ASCII、大小写都折算),
* 但切出来的文本是**原文**:高亮只加底色,一个字都不能被换成折叠后的样子。
* 折叠后下标与原文下标不是一一对应(U+FEFF 被丢掉、小写偶有变长),所以走 foldText 的
* origin 映射把命中范围还原回原文。
*
* 关键字为空时不切(整段算未命中),与「空查询 = 全部通过」一致。
*/
export function highlightSegments(text: string, keyword: string): SearchSegment[] {
const needle = foldSearchText(keyword)
if (needle === '' || text === '') return [{ text, hit: false }]
const folded = foldText(text)
const segments: SearchSegment[] = []
let cursor = 0
let scan = 0
while (scan + needle.length <= folded.text.length) {
const at = folded.text.indexOf(needle, scan)
if (at < 0) break
const endFolded = at + needle.length
const start = folded.origin[at] ?? text.length
const end = endFolded < folded.origin.length ? (folded.origin[endFolded] ?? text.length) : text.length
if (start > cursor) segments.push({ text: text.slice(cursor, start), hit: false })
segments.push({ text: text.slice(start, end), hit: true })
cursor = end
scan = endFolded
}
if (segments.length === 0) return [{ text, hit: false }]
if (cursor < text.length) segments.push({ text: text.slice(cursor), hit: false })
return segments
}
+45
View File
@@ -193,6 +193,51 @@ export function lanEmptyNote(network: NetworkInfo): string {
return `没找到可用的局域网地址。已检查:${shown.join('、')}${folded}。` return `没找到可用的局域网地址。已检查:${shown.join('、')}${folded}。`
} }
/**
* 点「二维码」按钮之后,当前该展开的是哪一条地址(`null` = 一条都没展开)。
*
* 口径有三个:再点同一条=收起、点另一条=切过去、没开时点任何一条=打开它。三件事都在
* 一次点击里决定,写反了(比如同一条点第二次却仍然是它)只有现场点一下才看得出来,
* 所以抽成纯函数,在 node 里把第 0 条与第 N 条都走一遍。同一时刻只留一个码就是靠它:
* 状态里存的是「哪一条」,不是「各条各开不开」。
*
* 泛型只是因为「是不是同一条」这个判断与身份类型无关:页面传 IP 字符串(列表的 key),
* 测试里传下标。
*/
export function toggleQr<T>(current: T | null, clicked: T): T | null {
return current === clicked ? null : clicked
}
/**
* 这条地址要不要给「二维码」按钮。
*
* 首条不给:它下面长期挂着一个码(现场一眼就能扫),再给个按钮会让人以为那个码还得再点一下
* 才出来;其余各条没有常驻码,才需要按钮临时代替。
*/
export function showsQrButton(index: number): boolean {
return index > 0
}
/**
* 「这是二维码自己的地盘,点它不算点空白处」的记号。页面上的按钮与临时二维码容器各带一处。
*
* 按钮上那一处是必须的:关闭监听挂在 document 上(冒泡末端,按钮自己的处理函数之后才轮到它),
* 少了它,按钮刚把码打开、紧接着就被这个监听关掉——按钮看起来完全没反应。
*/
export const LAN_QR_KEEP_SELECTOR = '[data-lan-qr-keep]'
/**
* 点空白处关闭时的排除判断:目标在自己带记号的元素内部就不算「空白处」。
*
* 抽成纯函数是为了能测:`closest` 是 DOM 接口、node 里没有,但按结构注入一个假的就能把
* 「带记号 → 不放行」与「真空白处 → 放行」两条路都跑一遍;写成页面里的内联判断则只剩
* 浏览器里人工点这一条验证途径,而它恰恰是最容易写漏的地方。
*/
export function isLanQrKeepTarget(target: { closest(selector: string): unknown } | null): boolean {
if (target === null || typeof target.closest !== 'function') return false
return target.closest(LAN_QR_KEEP_SELECTOR) !== null
}
/** 手填地址在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */ /** 手填地址在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */
export const MANUAL_LAN_STORAGE_KEY = 'scoreboard.display.manualLanAddresses' export const MANUAL_LAN_STORAGE_KEY = 'scoreboard.display.manualLanAddresses'
@@ -0,0 +1,161 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 改名接口的契约:`PUT /api/display/instances/{id}/alias`。
*
* 服务端那一半由另一个代理实现(权限与大屏切页同档 = 录入员及以上,别名跟着实例 id 机器级持久化)。
* 这里钉住前端这一侧:URL 与请求体形状、响应按列表元素同款解析、失败原文必须原样抛出。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body }
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
const LAN_DISPLAY = {
id: 'browser-a1b2c3d4',
online: true,
page: 'standings',
lastHeartbeat: '2026-09-11T10:00:02Z',
connectedAt: '2026-09-11T09:58:00Z',
remoteAddr: '192.168.1.23:52341',
ackVersion: 7,
slow: false,
screen: '',
device: '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机',
deviceRaw: 'Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36',
alias: '二号楼平板',
}
test('列表收下 device / deviceRaw / alias 三个新字段', async () => {
respondWith(() => jsonResponse({ ok: true, data: [LAN_DISPLAY] }))
const [instance] = await adminApi.displayInstances()
assert.equal(instance?.device, '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机')
assert.equal(instance?.deviceRaw, LAN_DISPLAY.deviceRaw)
assert.equal(instance?.alias, '二号楼平板')
})
// 老服务端 / 老客户端不带这三个字段(omitempty):缺字段时**整个键不出现**,而不是补空串。
// 这样界面上的「有别名 / 有设备标签」判断只有一个口径(undefined 即没有),老接口也不会被误判成回归。
test('老服务端缺这三个字段时不出现在对象里', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: [{ ...LAN_DISPLAY, device: undefined, deviceRaw: undefined, alias: undefined }],
}),
)
const [instance] = await adminApi.displayInstances()
assert.ok(instance !== undefined)
assert.equal('device' in instance, false)
assert.equal('deviceRaw' in instance, false)
assert.equal('alias' in instance, false)
// 空串同理(服务端 omitempty 之后也可能给出空串):按「没有」处理。
respondWith(() =>
jsonResponse({ ok: true, data: [{ ...LAN_DISPLAY, device: '', deviceRaw: '', alias: ' ' }] }),
)
const [blank] = await adminApi.displayInstances()
assert.equal(blank?.device, undefined)
assert.equal(blank?.alias, undefined)
})
test('改名用 PUT,请求体是 { alias },实例 id 走路径且做 URL 编码', async () => {
respondWith(() => jsonResponse({ ok: true, data: LAN_DISPLAY }))
const updated = await adminApi.displayAlias('browser-a1b2c3d4', '二号楼平板')
assert.equal(lastRequest.url, '/api/display/instances/browser-a1b2c3d4/alias')
assert.equal(lastRequest.method, 'PUT')
const body = JSON.parse(String(lastRequest.body)) as { alias?: unknown; requestId?: unknown }
assert.equal(body.alias, '二号楼平板')
// 写请求统一带幂等键(服务端外层 cached 认它),这一条不能因为新接口漏掉。
assert.equal(typeof body.requestId, 'string')
assert.equal(updated.alias, '二号楼平板')
assert.equal(updated.id, 'browser-a1b2c3d4')
// 带斜杠 / 空格的 id 也不会把路径拼坏。
await adminApi.displayAlias('a b/c', 'x')
assert.equal(lastRequest.url, '/api/display/instances/a%20b%2Fc/alias')
})
test('清除别名提交空串(服务端口径:空串或只空白 = 回到自动标签)', async () => {
respondWith(() => jsonResponse({ ok: true, data: { ...LAN_DISPLAY, alias: undefined } }))
const cleared = await adminApi.displayAlias('browser-a1b2c3d4', '')
assert.equal((JSON.parse(String(lastRequest.body)) as { alias?: unknown }).alias, '')
assert.equal(cleared.alias, undefined)
})
test('响应格式不符时抛解析失败,不把半截记录当成成功', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: '' } }))
await assert.rejects(
() => adminApi.displayAlias('browser-a1b2c3d4', 'x'),
(error: Error) => error.message.includes('别名'),
)
})
test('403 / 404 的服务端原文原样抛出,不吞成「操作失败」', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'FORBIDDEN', message: '需要录入员及以上权限' } }, 403),
)
await assert.rejects(
() => adminApi.displayAlias('browser-a1b2c3d4', 'x'),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 403)
assert.equal(error.message, '需要录入员及以上权限')
return true
},
)
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'NOT_FOUND', message: '实例已不存在,请刷新在线列表' } }, 404),
)
await assert.rejects(
() => adminApi.displayAlias('gone', 'x'),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.message, '实例已不存在,请刷新在线列表')
return true
},
)
})
// 写操作一律不降级:示例数据模式下返回「改好了」会让操作员以为名字真的存住了。
test('连不上服务端时改名抛错,不降级成假成功', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(() => adminApi.displayAlias('browser-a1b2c3d4', '二号楼平板'))
})
@@ -0,0 +1,97 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { DisplayInstance } from '@/shared/api'
import {
ALIAS_CLEAR_HINT,
ALIAS_RENAME_HINT,
aliasPayload,
instanceDisplayName,
instanceIdNote,
instanceTooltip,
} from '../pages/displayInstanceLabel'
/**
* 「在线大屏」列表怎么显示一台设备、改名怎么提交。
*
* 现场问题:局域网浏览器的名字是一串 `display-a1b2c3d4`,操作员分不清哪台是哪台。
* 名称优先级一旦写反(自动标签压过手动别名),操作员改完名字刷新一下又变回去,
* 他会以为改名根本没生效——所以这三件事都要钉住。
*/
/** 局域网浏览器:有自动设备标签,来源与屏号可能缺字段。 */
const LAN_DISPLAY: DisplayInstance = {
id: 'browser-a1b2c3d4',
online: true,
page: 'standings',
lastHeartbeat: '2026-09-11T10:00:02Z',
connectedAt: '2026-09-11T09:58:00Z',
remoteAddr: '192.168.1.23:52341',
ackVersion: 7,
slow: false,
screen: '',
device: '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机',
deviceRaw: 'Mozilla/5.0 (Linux; Android 13; 2211133C) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36',
}
test('名称优先级:手动别名 → 设备标签 → 实例 id', () => {
assert.equal(
instanceDisplayName({ ...LAN_DISPLAY, alias: '二号楼平板' }),
'二号楼平板',
'手动别名必须压过自动标签',
)
assert.equal(instanceDisplayName(LAN_DISPLAY), LAN_DISPLAY.device)
// 老客户端 / 老服务端没有 device:退回 id,至少排障时认得出是哪一条。
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, device: undefined }), 'browser-a1b2c3d4')
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, device: undefined, alias: undefined }), 'browser-a1b2c3d4')
})
test('只空白 / 空串的别名不算名字(清除后回到自动标签)', () => {
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, alias: ' ' }), LAN_DISPLAY.device)
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, alias: '' }), LAN_DISPLAY.device)
// 服务端把只空白当清除,界面这边也必须同口径,否则会出现一行看着有名字、实际是空白的条目。
assert.equal(instanceDisplayName({ ...LAN_DISPLAY, alias: ' ', device: undefined }), 'browser-a1b2c3d4')
})
test('id 降级:名字不是 id 时给一行小字,名字本身就是 id 时不重复渲染', () => {
assert.equal(instanceIdNote(LAN_DISPLAY), 'browser-a1b2c3d4')
assert.equal(instanceIdNote({ ...LAN_DISPLAY, alias: '二号楼平板' }), 'browser-a1b2c3d4')
assert.equal(instanceIdNote({ ...LAN_DISPLAY, alias: undefined, device: undefined }), null)
})
test('悬浮提示:设备 / 原始 UA / id / 来源 / 屏号逐行给出,缺的整行不出现', () => {
const lines = instanceTooltip(LAN_DISPLAY).split('\n')
assert.deepEqual(lines, [
`设备:${LAN_DISPLAY.device}`,
`原始 UA:${LAN_DISPLAY.deviceRaw}`,
'实例 id:browser-a1b2c3d4',
'来源:192.168.1.23:52341',
])
// 屏号是空串(局域网浏览器):不出现这一行,也不出现「屏号:」这种空壳。
assert.doesNotMatch(instanceTooltip(LAN_DISPLAY), /屏号/)
const consoleWindow: DisplayInstance = {
...LAN_DISPLAY,
id: 'LAPTOP-1-2528732444',
screen: '2',
device: undefined,
deviceRaw: undefined,
remoteAddr: '',
}
assert.deepEqual(instanceTooltip(consoleWindow).split('\n'), ['实例 id:LAPTOP-1-2528732444', '屏号:2'])
})
test('别名请求体:首尾空白去掉,空串 / 纯空白按清除提交', () => {
assert.deepEqual(aliasPayload(' 二号楼平板 '), { alias: '二号楼平板' })
assert.deepEqual(aliasPayload('二号楼 平板'), { alias: '二号楼 平板' })
assert.deepEqual(aliasPayload(''), { alias: '' })
assert.deepEqual(aliasPayload(' '), { alias: '' })
// 全角空格也是空白(现场从聊天记录里粘过来的名字常带它)。
assert.deepEqual(aliasPayload(' '), { alias: '' })
})
test('两句话提示与按钮 title 口径一致(都不为空且不是同一句)', () => {
assert.ok(ALIAS_RENAME_HINT.includes('只对这台设备生效'))
assert.ok(ALIAS_CLEAR_HINT.includes('设备标签'))
assert.notEqual(ALIAS_RENAME_HINT, ALIAS_CLEAR_HINT)
})
+139 -2
View File
@@ -1,7 +1,16 @@
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { athleteCellName, inlineBibLabel } from '../pages/entryRowDisplay' import {
ATHLETE_TOGGLE_THRESHOLD,
athleteCellClass,
athleteCellName,
athleteTextWidth,
athleteToggleLabel,
athleteToggleTitle,
inlineBibLabel,
shouldOfferAthleteToggle,
} from '../pages/entryRowDisplay'
// 姓名后重复一次号码布:录入时不用在「号码」列与姓名列之间来回跳。 // 姓名后重复一次号码布:录入时不用在「号码」列与姓名列之间来回跳。
test('有号码布:原样展示(去掉首尾空白)', () => { test('有号码布:原样展示(去掉首尾空白)', () => {
@@ -29,12 +38,140 @@ test('姓名主文本:个人行用姓名,团体 / 接力行回落队员名
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
test('录入页姓名单元格紧随号码布,且用 v-if 保证空号码不占位', () => { test('录入页姓名单元格紧随号码布,且用 v-if 保证空号码不占位', () => {
const cell = ENTRY_SOURCE.match(/<td>\{\{ athleteCellName\(row\) \}\}([\s\S]*?)<\/td>/)?.[1] const cell = ENTRY_SOURCE.match(/<td>\s*<span[\s\S]*?<\/td>/)?.[0]
assert.ok(cell !== undefined, '找不到姓名单元格') assert.ok(cell !== undefined, '找不到姓名单元格')
assert.match(cell, /<span v-if="inlineBibLabel\(row\.bib\)"/, '号码布必须带 v-if,空值不渲染') assert.match(cell, /<span v-if="inlineBibLabel\(row\.bib\)"/, '号码布必须带 v-if,空值不渲染')
assert.match(cell, /class="bib-inline mono"/, '号码布要小号、弱化、等宽') assert.match(cell, /class="bib-inline mono"/, '号码布要小号、弱化、等宽')
assert.match(cell, /class="athlete-cell-name"/, '姓名文本在自己的元素里,才能单独限宽截断')
}) })
test('原来的「号码」列保留,没有被这次改动删掉', () => { test('原来的「号码」列保留,没有被这次改动删掉', () => {
assert.match(ENTRY_SOURCE, /<td class="mono">\{\{ row\.bib \?\? '—' \}\}<\/td>/) assert.match(ENTRY_SOURCE, /<td class="mono">\{\{ row\.bib \?\? '—' \}\}<\/td>/)
}) })
// —— 多人团体 / 接力的长名单:收起限宽、手动展开 ——
// 显示宽度口径:CJK / 全角算 2,半角算 1。
test('显示宽度:CJK 与全角括号算 2、半角算 1', () => {
assert.equal(athleteTextWidth(''), 0)
assert.equal(athleteTextWidth('张三'), 4)
assert.equal(athleteTextWidth('A001'), 4)
// 「张伟(A001)」= 2 + 2 + 2(全角左括号)+ 4 + 2(全角右括号)
assert.equal(athleteTextWidth('张伟(A001)'), 12)
assert.equal(athleteTextWidth('张伟(A001)、李强(B002)'), 26)
})
// 个人项目:短姓名永远不该出现「展开」,否则每一行都多一个按了没用的按钮。
test('短文本(个人项目的姓名)不出现展开按钮', () => {
assert.equal(shouldOfferAthleteToggle('张三'), false)
assert.equal(shouldOfferAthleteToggle('—'), false)
// 少数民族姓名是最长的个人姓名形态,仍然不该出现按钮。
assert.equal(shouldOfferAthleteToggle('阿依古丽·买买提'), false)
})
// 多人团体 / 接力的名单:4–8 个人的「姓名(号码布)」串必须出现按钮。
test('多人团体的长串(4–8 人)出现展开按钮', () => {
const four = '张伟(A001)、李强(A002)、王芳(A003)、刘洋(A004)'
const eight = '张伟(A001)、李强(A002)、王芳(A003)、刘洋(A004)、赵敏(A005)、孙磊(A006)、周涛(A007)、吴静(A008)'
assert.equal(shouldOfferAthleteToggle(four), true)
assert.equal(shouldOfferAthleteToggle(eight), true)
// 阈值是 24,四个人(54)与八个人(110)都远超,长到这个程度的名单才会被截断。
assert.ok(athleteTextWidth(four) > ATHLETE_TOGGLE_THRESHOLD)
assert.ok(athleteTextWidth(eight) > ATHLETE_TOGGLE_THRESHOLD)
})
// 12 个汉字 = 24 个显示宽度,正好压在阈值上。
const EDGE_CJK = '甲'.repeat(ATHLETE_TOGGLE_THRESHOLD / 2)
// 边界口径:**严格大于**阈值才出现按钮,正好等于阈值(不含等于)不出现。
test('阈值边界:正好等于阈值不给按钮(不含等于),多 1 个显示宽度才给', () => {
assert.equal(athleteTextWidth(EDGE_CJK), ATHLETE_TOGGLE_THRESHOLD)
assert.equal(shouldOfferAthleteToggle(EDGE_CJK), false, '等于阈值:不含')
assert.equal(shouldOfferAthleteToggle(`${EDGE_CJK}甲`), true, '超过阈值 1(半角口径 +2):含')
})
// 宽度口径证明:不用 text.length,否则 8 个汉字与 16 个半角字符会给出不同结论。
test('CJK 宽度口径:8 个汉字与 16 个半角字符判定一致', () => {
const cjk = '一二三四五六七八'
const ascii = 'abcdefghijklmnop'
assert.equal(cjk.length, 8)
assert.equal(ascii.length, 16)
assert.equal(athleteTextWidth(cjk), athleteTextWidth(ascii))
assert.equal(shouldOfferAthleteToggle(cjk), shouldOfferAthleteToggle(ascii))
// 阈值上下各再钉一对,确保两个口径在边界上也是一致的。
assert.equal(shouldOfferAthleteToggle(EDGE_CJK), false)
assert.equal(shouldOfferAthleteToggle('a'.repeat(ATHLETE_TOGGLE_THRESHOLD)), false)
assert.equal(shouldOfferAthleteToggle(`${EDGE_CJK}甲`), true)
assert.equal(shouldOfferAthleteToggle(`a${'a'.repeat(ATHLETE_TOGGLE_THRESHOLD)}`), true)
})
test('展开 / 收起文案与 class', () => {
assert.equal(athleteToggleLabel(false), '展开')
assert.equal(athleteToggleLabel(true), '收起')
assert.equal(athleteCellClass(false), '')
assert.equal(athleteCellClass(true), 'is-expanded')
assert.notEqual(athleteToggleTitle(false), athleteToggleTitle(true))
})
/*
* 模板契约:`.vue` 跑不进测试,接线忘了没人拦得住,所以直接读源码断言。
* 这里要盯住三件事:姓名文本在 athlete-cell-text 里、展开状态确实驱动了它的 class、
* 展开按钮带 @click.stop(这一格旁边还有单位格的弹窗入口,冒泡误触会一起打开)。
*/
const ATHLETE_CELL =
ENTRY_SOURCE.match(/<td>[\s\S]*?athlete-cell-text[\s\S]*?<\/td>/)?.[0] ?? ''
test('姓名文本放进 .athlete-cell-text,且展开状态绑定到它的 class', () => {
assert.ok(ATHLETE_CELL !== '', '找不到姓名单元格')
assert.match(ATHLETE_CELL, /class="athlete-cell-text"/)
assert.match(ATHLETE_CELL, /:class="athleteCellClass\(athleteCellExpanded\(row\)\)"/)
// 姓名文本仍在自己的 .athlete-cell-name 元素里(限宽截断就挂在它身上)。
// 录入页的搜人框只在**搜索时**把这一句换成命中片段(见 entry-search.test.ts):
// 没在搜索时走的就是这一条分支,渲染与搜索功能加入之前逐字相同。
assert.match(ATHLETE_CELL, /<span v-if="!searching" class="athlete-cell-name">\{\{ athleteCellName\(row\) \}\}<\/span>/)
})
test('展开按钮:只在长名单出现、文案跟着状态、点击不冒泡', () => {
assert.match(ATHLETE_CELL, /v-if="shouldOfferAthleteToggle\(athleteCellName\(row\)\)"/)
assert.match(ATHLETE_CELL, /class="mx-btn mx-btn-ghost mx-btn-sm athlete-toggle"/)
assert.match(ATHLETE_CELL, /\{\{ athleteToggleLabel\(athleteCellExpanded\(row\)\) \}\}/)
assert.match(ATHLETE_CELL, /@click\.stop="toggleAthleteCell\(row\)"/, '必须 @click.stop,别冒泡到单位格的弹窗')
assert.ok(!/@click="toggleAthleteCell/.test(ATHLETE_CELL), '不允许没有 .stop 的写法')
})
test('「队员」按钮与号码布没被这次改动动过', () => {
assert.match(ATHLETE_CELL, /v-if="entryAthletesEditable\(row\)"/)
assert.match(ATHLETE_CELL, /@click="openAthleteEditor\(row\)"/)
assert.match(ATHLETE_CELL, /<span v-if="inlineBibLabel\(row\.bib\)" class="bib-inline mono">/)
})
/*
* 样式契约:限宽与截断全在 CSS 里,同样没法在测试里渲染,只能读源码。
* 另外钉住「限宽 ≥ 阈值」这条关系:短于阈值的文本不该被截断(那时没有按钮可点)。
*/
test('收起态限宽 + 省略号,展开态放开限宽并允许换行', () => {
const box = ENTRY_SOURCE.match(/\.athlete-cell-text \{([\s\S]*?)\}/)?.[1] ?? ''
const name = ENTRY_SOURCE.match(/\.athlete-cell-name \{([\s\S]*?)\}/)?.[1] ?? ''
const expanded = ENTRY_SOURCE.match(/\.athlete-cell-text\.is-expanded \{([\s\S]*?)\}/)?.[1] ?? ''
assert.match(box, /display: inline-block/)
assert.match(box, /max-width: 14em/)
assert.match(name, /overflow: hidden/)
assert.match(name, /text-overflow: ellipsis/)
assert.match(name, /white-space: nowrap/)
assert.match(expanded, /max-width: none/)
assert.match(
ENTRY_SOURCE,
/\.athlete-cell-text\.is-expanded \.athlete-cell-name \{[\s\S]*?white-space: normal/,
'展开态要允许换行,否则展开也看不到全文',
)
})
test('CSS 限宽不小于阈值:不到阈值的文本不会被截断(那时没有按钮可点)', () => {
const em = Number(ENTRY_SOURCE.match(/\.athlete-cell-text \{[\s\S]*?max-width: ([\d.]+)em/)?.[1])
assert.ok(Number.isFinite(em), '找不到 .athlete-cell-text 的 em 限宽')
// 1 个显示宽度 ≈ 0.5em(半角字符),所以限宽换算成显示宽度要 ≥ 阈值。
assert.ok(
em * 2 >= ATHLETE_TOGGLE_THRESHOLD,
`限宽 ${em}em(${em * 2} 个显示宽度)小于阈值 ${ATHLETE_TOGGLE_THRESHOLD}`,
)
})
+353
View File
@@ -0,0 +1,353 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
SEARCH_EMPTY_HINT,
SEARCH_SCOPE_NOTE,
entryRowMatchesKeyword,
filterEntryRows,
highlightSegments,
isSearching,
searchCountText,
searchEmptyTitle,
} from '../pages/entrySearch'
import { bibMatchesKeyword } from '../components/baseData/entrySelection'
import { filterRowsByHeat } from '../pages/entryHeat'
/**
* 录入页的搜人框(5 公里 / 3 公里这种大项目一屏几百行,找一个名字要找半天)。
*
* 这里钉两件事:
* 1. **匹配口径**(姓名 / 号码布、大小写与全角空格、007 与 7、团体行的队员名单);
* 2. **它只是查看工具**:过滤出来的行只喂给表格渲染,发布范围、进度条分母、勾选范围、
* 清空范围与预览算分读的仍是批次口径的 visibleRows。第 1 条是纯函数,第 2 条
* `.vue` 跑不进 node --test(无 jsdom),只能读源码断言,仓库里有先例。
*/
/** 个人行:姓名 + 号码布(号码布带前导零,现场就是这么写的)。 */
const zhang = { athleteName: '张伟', bib: '007', memberNames: null, athletes: [] }
/** 号码布为空的行:老数据 / 还没发号码布都很常见。 */
const wang = { athleteName: '王芳', bib: null, memberNames: null, athletes: [] }
/** 团体 / 接力行:姓名列为空,身份在文字名单与队员关联里。 */
const relay = {
athleteName: '',
bib: '021',
memberNames: ['李强', '赵敏'],
athletes: [
{ name: '李强', bib: 'A001' },
{ name: '赵敏', bib: null },
],
}
// ── 姓名 ────────────────────────────────────────────────────────────────────
test('按姓名命中:整名与片段都算(模糊搜索)', () => {
assert.equal(entryRowMatchesKeyword(zhang, '张'), true)
assert.equal(entryRowMatchesKeyword(zhang, '张伟'), true)
assert.equal(entryRowMatchesKeyword(zhang, '伟'), true, '名里的一两个字也要能搜到')
assert.equal(entryRowMatchesKeyword(zhang, '王芳'), false)
})
// ── 号码布 ──────────────────────────────────────────────────────────────────
test('按号码布命中:前导零两种写法互相都能搜到', () => {
// 系统里存的就是现场写的那串(007),直接搜它。
assert.equal(entryRowMatchesKeyword(zhang, '007'), true)
// 手上只看得出是「7 号」时也要能搜到 007。
assert.equal(entryRowMatchesKeyword(zhang, '7'), true)
assert.equal(entryRowMatchesKeyword(zhang, '07'), true)
// 反方向:系统里存的是 7、纸上写的是 007,同样要互相搜得到(数字口径,见 bibMatchesKeyword)。
assert.equal(bibMatchesKeyword('7', '007'), true)
assert.equal(bibMatchesKeyword('7', '7'), true)
assert.equal(bibMatchesKeyword('007', '7'), true)
assert.equal(entryRowMatchesKeyword({ ...zhang, bib: '7' }, '007'), true)
// 数字不同就是不同的人,别把整张表搜成一锅粥。
assert.equal(entryRowMatchesKeyword(zhang, '8'), false)
assert.equal(bibMatchesKeyword('007', '8'), false)
// 只有两侧都是纯数字才走数字口径:带字母的号码布仍按文本包含,不被数字口径牵连。
assert.equal(bibMatchesKeyword('A001', 'A00'), true)
assert.equal(bibMatchesKeyword('A001', '007'), false, 'A001 不是纯数字,别拿 007 去对上它')
})
// ── 大小写 / 空白 / 全角空格 ────────────────────────────────────────────────
test('大小写不敏感、首尾空格忽略', () => {
const latin = { athleteName: 'Zhang Wei', bib: 'a001', memberNames: null, athletes: [] }
assert.equal(entryRowMatchesKeyword(latin, 'zhang'), true)
assert.equal(entryRowMatchesKeyword(latin, ' Zhang Wei '), true)
assert.equal(entryRowMatchesKeyword(latin, 'A001'), true, '号码布大小写不敏感')
assert.equal(entryRowMatchesKeyword(latin, ' a001 '), true)
})
test('全角空格当空白处理:输入法打出来的空格不能让人搜不到', () => {
// 关键字两侧带全角空格(\u3000)= 没在搜任何东西之外的东西,等价于去掉了。
assert.equal(entryRowMatchesKeyword(zhang, '\u3000张伟\u3000'), true)
assert.equal(isSearching('\u3000'), false, '只有全角空格 = 没在搜')
assert.equal(entryRowMatchesKeyword(zhang, '\u3000'), true, '空查询 = 全部通过')
// 名字里带全角空格(从聊天记录 / Excel 粘过来的)也要能按「张 伟」搜到。
const spaced = { athleteName: '张\u3000伟', bib: null, memberNames: null, athletes: [] }
assert.equal(entryRowMatchesKeyword(spaced, '张 伟'), true, '关键字半角空格也要命中名字里的全角空格')
assert.equal(entryRowMatchesKeyword(spaced, '张\u3000伟'), true)
// 全角字母同样折叠:号码布被输入法打成全角也能搜到。
const wide = { athleteName: '李强', bib: 'A001', memberNames: null, athletes: [] }
assert.equal(entryRowMatchesKeyword(wide, 'a001'), true)
})
// ── 空查询 / 空号码布 ───────────────────────────────────────────────────────
test('空查询 = 全部通过(不能把表格筛空)', () => {
const rows = [zhang, wang, relay]
assert.equal(isSearching(''), false)
assert.equal(isSearching(' '), false)
for (const row of rows) assert.equal(entryRowMatchesKeyword(row, ''), true)
const filtered = filterEntryRows(rows, ' ')
assert.deepEqual(filtered, rows)
// 返回的是新数组:调用方(computed)不该拿到同一个引用被下游误改。
assert.notEqual(filtered, rows)
})
test('号码布为空的行不因此被误杀:按姓名照样搜得到,也不会被号码布关键字连坐', () => {
// 号码布为空只影响「按号码布搜」这一维:按姓名搜仍然命中。
assert.equal(entryRowMatchesKeyword(wang, '王芳'), true)
assert.deepEqual(filterEntryRows([zhang, wang], '王'), [wang])
// 反过来,搜号码布时号码布为空的行不该跟着冒出来(空串不能匹配任何关键字)。
assert.deepEqual(filterEntryRows([zhang, wang], '7'), [zhang])
assert.equal(entryRowMatchesKeyword(wang, '007'), false)
})
// ── 团体行 ──────────────────────────────────────────────────────────────────
test('团体行按队员姓名或队员号码布能搜到(身份在那份名单里)', () => {
assert.equal(entryRowMatchesKeyword(relay, '李强'), true, '文字名单里的队员')
assert.equal(entryRowMatchesKeyword(relay, '赵敏'), true)
assert.equal(entryRowMatchesKeyword(relay, 'A001'), true, '队员关联里的号码布')
assert.equal(entryRowMatchesKeyword(relay, '021'), true, '行自己的号码布也要能搜到')
// 姓名列为空(团体行就是这样)不能变成「谁都能匹配」。
assert.equal(entryRowMatchesKeyword(relay, '陈'), false)
assert.deepEqual(filterEntryRows([zhang, relay], '赵'), [relay])
})
// ── 与批次筛选是 AND ────────────────────────────────────────────────────────
test('搜索与批次筛选是 AND:两个都生效', () => {
const rows = [
{ entryId: 1, heat: 1, ...zhang },
{ entryId: 2, heat: 2, ...zhang },
{ entryId: 3, heat: 1, ...wang },
]
const ids = (list: { entryId: number }[]): number[] => list.map((row) => row.entryId)
// 同一批 + 同一关键字 → 交集。
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 1), '张伟')), [1])
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 2), '张伟')), [2])
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 'all'), '张伟')), [1, 2])
// 只筛选批次(没搜)→ 还是整批;只搜(批次 = 全部)→ 所有命中的人。
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 1), '')), [1, 3])
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 'all'), '王')), [3])
// 批次里没有这个人 → 空表(不是「忽略批次照搜」)。
assert.deepEqual(ids(filterEntryRows(filterRowsByHeat(rows, 'none'), '张伟')), [])
})
// ── 结果提示与空状态文案 ────────────────────────────────────────────────────
test('结果提示:找到 N 条 / 共 M 条(M 是批次筛选后的行数)', () => {
assert.equal(searchCountText(3, 120), '找到 3 条 / 共 120 条')
assert.equal(searchCountText(0, 120), '找到 0 条 / 共 120 条')
assert.equal(searchCountText(120, 120), '找到 120 条 / 共 120 条')
})
test('空状态:回显关键字,并说清可以只搜一部分', () => {
assert.equal(searchEmptyTitle('某某'), '没有匹配「某某」的运动员')
// 首尾空白去掉再回显(输入法容易多打一个空格),里面的空格原样保留。
assert.equal(searchEmptyTitle(' 某某 '), '没有匹配「某某」的运动员')
assert.equal(searchEmptyTitle(' 张 伟 '), '没有匹配「张 伟」的运动员')
assert.match(SEARCH_EMPTY_HINT, /号码布/)
assert.match(SEARCH_EMPTY_HINT, /007/)
})
test('搜索不影响发布与进度这句话本身要写全', () => {
assert.match(SEARCH_SCOPE_NOTE, /只影响表格显示/)
assert.match(SEARCH_SCOPE_NOTE, /不影响发布范围/)
assert.match(SEARCH_SCOPE_NOTE, /进度/)
})
// ── 命中高亮(纯函数) ──────────────────────────────────────────────────────
test('高亮切分:命中的一段 hit = true,其余 false,拼回去等于原文', () => {
assert.deepEqual(highlightSegments('张伟', '伟'), [
{ text: '张', hit: false },
{ text: '伟', hit: true },
])
assert.deepEqual(highlightSegments('张伟', '张伟'), [{ text: '张伟', hit: true }])
assert.deepEqual(highlightSegments('张伟张伟', '张伟'), [
{ text: '张伟', hit: true },
{ text: '张伟', hit: true },
])
})
test('高亮用的是原文:大小写与全角只影响「找得到」,不改写表格里的字', () => {
assert.deepEqual(highlightSegments('Zhang Wei', 'zhang'), [
{ text: 'Zhang', hit: true },
{ text: ' Wei', hit: false },
])
// 全角括号 / 全角字母在原文里保持全角,命中范围按折叠后的位置还原。
assert.deepEqual(highlightSegments('张三(007)', '007'), [
{ text: '张三(', hit: false },
{ text: '007', hit: true },
{ text: ')', hit: false },
])
assert.deepEqual(highlightSegments('A001', 'a001'), [{ text: 'A001', hit: true }])
})
test('高亮:空关键字与空文本都不切,拼回去永远等于原文', () => {
assert.deepEqual(highlightSegments('张伟', ''), [{ text: '张伟', hit: false }])
assert.deepEqual(highlightSegments('张伟', ' '), [{ text: '张伟', hit: false }])
assert.deepEqual(highlightSegments('', '张'), [{ text: '', hit: false }])
assert.deepEqual(highlightSegments('张伟', '李'), [{ text: '张伟', hit: false }])
const cases: Array<[string, string]> = [
['张伟(A001)、李强(B002)', '李强'],
[' 张 伟 ', '张 伟'],
['张三(007)', '7'],
['emoji🏃的姓名', 'emoji'],
['emoji🏃的姓名', '🏃'],
]
for (const [text, keyword] of cases) {
const segments = highlightSegments(text, keyword)
assert.equal(segments.map((segment) => segment.text).join(''), text, `${text} / ${keyword}`)
// 至少有一段命中,否则这几条用例证明不了什么。
assert.ok(segments.some((segment) => segment.hit), `${text} / ${keyword} 应该有命中片段`)
}
})
// ── 源码级接线断言(.vue 跑不进测试) ────────────────────────────────────────
const ENTRY = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const SEARCH_MODULE = readFileSync(new URL('../pages/entrySearch.ts', import.meta.url), 'utf8')
/**
* 匹配口径只许有一份实现:录入页这一份复用 entrySelection.ts 里的共用函数
* (补报名的选人搜索框用的是同一份,见 athlete-search-bib.test.ts)。
*/
test('不许有第三份匹配逻辑:录入页的搜索模块复用共用口径', () => {
assert.match(
SEARCH_MODULE,
/import \{[\s\S]*?athleteMatchesKeyword[\s\S]*?\} from '\.\.\/components\/baseData\/entrySelection'/,
)
assert.match(SEARCH_MODULE, /athleteMatchesKeyword\(/)
assert.match(SEARCH_MODULE, /bibMatchesKeyword|athleteMatchesKeyword/)
// 自己再写一遍 toLowerCase().includes(...) 就是第三份。
assert.doesNotMatch(SEARCH_MODULE, /\.toLowerCase\(\)\.includes\(/)
})
/**
* 「不影响发布」:发布请求体里不许出现搜索词,发布范围也不许跟着过滤后的行数缩水。
* 服务端本来就按「小项 + 组别」发布所有已填行,前端这一层不许把搜索塞进去。
*/
test('发布请求里没有搜索词,范围也没跟着搜索结果缩水', () => {
const call = /await adminApi\.publish\(\{[\s\S]{0,220}?\}\)/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(call, '找不到 adminApi.publish 调用')
assert.match(call, /eventId: selectedEventId\.value/)
assert.match(call, /categoryId: selectedCategoryId\.value \?\? undefined/)
assert.match(call, /rankOnly: rankOnlyOf\(finalPublish\.value\)/)
assert.doesNotMatch(call, /search|keyword|searchQuery|displayRows|visibleRows|pendingRows/)
// 待发布的行(按钮可用性与回执人数)仍按批次口径,不读过滤后的列表。
assert.match(ENTRY, /const pendingRows = computed\(\(\) =>\s*\n?\s*visibleRows\.value\.filter/)
assert.doesNotMatch(ENTRY, /pendingRows = computed\(\(\) =>\s*\n?\s*displayRows/)
})
/**
* 「不影响进度条」:分母与计数走 entryProgress({ rows: categoryRows, filter: selectedHeat }),
* 也就是**批次口径**;表格渲染那一份(displayRows)不许出现在它里面。
*/
test('进度条按批次口径计数,不读过滤后的列表', () => {
const progress = /const entryBatchProgress = computed\(\(\) =>[\s\S]*?\n\)/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(progress, '找不到 entryBatchProgress')
assert.match(progress, /rows: categoryRows\.value/, '进度条的分母仍是整组 / 整批的行')
assert.match(progress, /filter: selectedHeat\.value/, '只按批次筛选')
assert.doesNotMatch(progress, /displayRows|searchQuery/)
// 工具栏的「已填 / 未录 / 有错」徽标同样是批次口径。
assert.match(ENTRY, /const stateCounts = computed\(\(\) => \{[\s\S]*?for \(const row of visibleRows\.value\)/)
})
test('表格渲染用的是 displayRows,其它口径仍是 visibleRows', () => {
// 两个列表的分工就写在这两行上:displayRows = 批次筛选之后再按搜索词过滤。
assert.match(
ENTRY,
/const displayRows = computed\(\(\) => filterEntryRows\(visibleRows\.value, searchQuery\.value\)\)/,
)
assert.match(ENTRY, /v-for="row in displayRows"/)
assert.doesNotMatch(ENTRY, /v-for="row in visibleRows"/)
// 勾选范围、预览算分与粘贴顺序仍是批次口径(搜索不改变任何写操作与算分的范围)。
assert.match(ENTRY, /const selectedRows = computed\(\(\) =>\s*\n?\s*visibleRows\.value\.filter/)
assert.doesNotMatch(ENTRY, /selectedRows = computed\(\(\) =>\s*\n?\s*displayRows/)
assert.match(ENTRY, /const score = computed\(\(\) => scoreWith\(visibleRows\.value, previewTeamRanks\.value\)\)/)
})
/** 「搜索不影响发布」这句话必须在界面上说清,而不是只写在注释里。 */
test('搜索框旁边常驻那句「不影响发布范围与进度」', () => {
assert.match(ENTRY, /\{\{ SEARCH_SCOPE_NOTE \}\}/)
assert.match(ENTRY, /:title="SEARCH_SCOPE_NOTE"/)
// 结果提示:找到 N 条 / 共 M 条,N 是命中数、M 是批次筛选后的总数。
assert.match(ENTRY, /searchCountText\(displayRows\.value\.length, visibleRows\.value\.length\)/)
assert.match(ENTRY, /\{\{ searchCountLabel \}\}/)
})
test('搜索框:输入即时过滤、清除按钮与 Esc 都接上了', () => {
assert.match(ENTRY, /const searchQuery = ref\(''\)/)
assert.match(ENTRY, /v-model="searchQuery"/, 'v-model 即时过滤')
assert.ok(ENTRY.includes('placeholder="搜姓名 / 号码布"'), '占位文案要写清搜的是姓名与号码布')
assert.match(ENTRY, /data-search-input/, 'Alt+Enter 的守卫要靠这个标记认出搜索框')
assert.match(ENTRY, /@keydown\.esc="clearSearch"/, 'Esc 清空')
const clear = /<button\s+v-if="searching"\s+class="entry-search-clear"[\s\S]*?<\/button>/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(clear, '找不到清除按钮')
assert.match(clear, /@click="clearSearch"/)
assert.match(clear, /MxIcon name="close"/)
assert.match(ENTRY, /function clearSearch\(\): void \{\s*\n\s*searchQuery\.value = ''\s*\n\}/)
// 不加防抖:一屏几十到几百行,纯前端字符串匹配是瞬时的。
assert.doesNotMatch(ENTRY, /debounce|throttle|searchTimer/)
})
test('搜不到时给的是空状态而不是一张空表:回显关键字 + 一键清除', () => {
assert.match(ENTRY, /\{\{ searchEmptyLabel \}\}/)
assert.match(ENTRY, /\{\{ SEARCH_EMPTY_HINT \}\}/)
// 搜索空状态与「该批次没有报名项」是两条不同的提示,别互相盖住。
assert.match(
ENTRY,
/<tr v-if="!loading && searching && visibleRows\.length > 0 && displayRows\.length === 0">[\s\S]*?清除搜索[\s\S]*?<\/tr>/,
)
assert.match(ENTRY, /<tr v-if="!loading && visibleRows\.length === 0">/)
})
test('搜索框里的 Alt+Enter 不触发发布(页面级快捷键只加守卫,语义不变)', () => {
const handler = /function onPageKeydown\(event: KeyboardEvent\): void \{[\s\S]*?\n\}/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(handler, '找不到 onPageKeydown')
assert.match(handler, /target\.dataset\.searchInput !== undefined/)
// 来源判断必须在 preventDefault 之前(否则搜索框里按下去已经被拦了)。
assert.ok(
handler.indexOf('dataset.searchInput') < handler.indexOf('preventDefault'),
'守卫要放在 preventDefault 之前',
)
// 既有快捷键的语义一个字都没动:仍是 Alt+Enter 发布、Ctrl+Enter 存草稿。
assert.match(handler, /if \(event\.altKey\) requestPublish\(\)/)
assert.match(handler, /else void saveDraft\(\)/)
// 只挡搜索框:成绩格里的 Alt+Enter 仍是发布(那是录入动作的一部分)。
assert.doesNotMatch(handler, /tagName === 'INPUT'|instanceof HTMLInputElement\) return/)
})
test('换小项 / 换组别时搜索归零(与批次筛选、行选择同一个理由)', () => {
const selectCategory = /function selectCategory\(id: number\): void \{[\s\S]*?\n\}/.exec(ENTRY)?.[0] ?? ''
assert.match(selectCategory, /clearSearch\(\)/)
assert.match(ENTRY, /selectedHeat\.value = 'all'\s*\n\s*selectedEntryIds\.value = \[\]\s*\n\s*(\/\/[^\n]*\n\s*)*clearSearch\(\)/)
})
test('高亮接在姓名的那个元素里,且没在搜索时渲染与改造前逐字相同', () => {
const cell = /<td>[\s\S]*?athlete-cell-text[\s\S]*?<\/td>/.exec(ENTRY)?.[0] ?? ''
assert.notEqual(cell, '找不到姓名单元格')
// 没在搜索:还是原来那一句(eng 限宽截断 / 展开按钮都挂在它上面)。
assert.match(cell, /<span v-if="!searching" class="athlete-cell-name">\{\{ athleteCellName\(row\) \}\}<\/span>/)
assert.match(
cell,
/highlightSegments\(athleteCellName\(row\), searchQuery\)/,
'搜索时才切成命中 / 未命中片段',
)
assert.match(cell, /<mark v-if="segment\.hit" class="entry-hit">/)
})
+6 -2
View File
@@ -124,10 +124,14 @@ test('页面的地址只来自 lanDisplayLinks(),不写死端口或路径', ()
assert.doesNotMatch(PAGE, /19540/) assert.doesNotMatch(PAGE, /19540/)
}) })
test('二维码只给第一个地址,且用的是矢量 SVG', () => { test('二维码:首条常驻一个,其余各条点按钮按需出码,都是矢量 SVG', () => {
// 首条常驻(位置、index === 0 的判据都没动);其余各条没有常驻码,点了才出——
// 「点空白处关闭 / 懒生成」那些口径在 lan-qr-toggle.test.ts 里。
assert.match(PAGE, /v-if="index === 0 && lanQrSvg !== null"/) assert.match(PAGE, /v-if="index === 0 && lanQrSvg !== null"/)
assert.match(PAGE, /v-if="openLanQrIp === item\.ip && openLanQrSvg !== null"/)
assert.match(PAGE, /import \{ encodeQr, qrToSvg \} from '@\/shared\/qrcode'/) assert.match(PAGE, /import \{ encodeQr, qrToSvg \} from '@\/shared\/qrcode'/)
assert.match(PAGE, /qrToSvg\(first\.url, \{ scale, margin: QR_MARGIN, ecc: 'M' \}\)/) // 编码参数(scale 由模块数反推、margin、ecc)一个字没变:常驻码与按需码共用这一处。
assert.match(PAGE, /qrToSvg\(url, \{ scale, margin: QR_MARGIN, ecc: 'M' \}\)/)
}) })
test('复制按钮走 copyText(含非安全上下文回退),失败有提示', () => { test('复制按钮走 copyText(含非安全上下文回退),失败有提示', () => {
+151
View File
@@ -0,0 +1,151 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
isLanQrKeepTarget,
LAN_QR_KEEP_SELECTOR,
showsQrButton,
toggleQr,
} from '../pages/lanDisplay'
/**
* 「非首条的临时二维码」的开关口径与接线。
*
* 需求(主人原话):卡片里只有第 1 条地址自动出码,余下各条要有个按钮点了临时出码、
* 点空白处自动关闭。三件事最容易写漏,所以各钉一处:
* 1. 状态推进(同一条再点=收起 / 点另一条=切过去)——纯函数,这里直接跑;
* 2. 懒生成——一个码是几百个 `<rect>`,渲染时为每条地址都算一次会把首屏拖慢;
* 3. 「点空白处关闭」的监听必须排除自己的按钮,否则点按钮会先关再开(按钮再也关不掉),
* 且必须有对应的摘除(这两个页面是长开窗口,漏摘会越积越多)。
*
* 页面级测试在仓库里跑不起来(没有 jsdom / @vue/test-utils),所以组件那半边退化成源码断言。
*/
const PAGE = readFileSync(new URL('../pages/DisplayPage.vue', import.meta.url), 'utf8')
/** 地址列表那一段模板(`v-for` 到 `</template>`),用来断言「渲染时不为每条地址生成二维码」。 */
function rowBlock(): string {
const start = PAGE.indexOf('v-for="(item, index) in lanLinks"')
const end = PAGE.indexOf('</template>', start)
return start < 0 || end < 0 ? '' : PAGE.slice(start, end)
}
test('toggleQr:没开时点一条=打开它(第 0 条与第 N 条都要能开)', () => {
assert.equal(toggleQr(null, 0), 0)
assert.equal(toggleQr(null, 3), 3)
assert.equal(toggleQr(null, '192.168.1.23'), '192.168.1.23')
})
test('toggleQr:同一条再点一次=收起', () => {
assert.equal(toggleQr(0, 0), null)
assert.equal(toggleQr(3, 3), null)
assert.equal(toggleQr('192.168.1.23', '192.168.1.23'), null)
})
test('toggleQr:点另一条=切过去,同一时刻只留一个码', () => {
assert.equal(toggleQr(1, 4), 4)
assert.equal(toggleQr(4, 1), 1)
// 状态里存的是「哪一条」,不是「各条各开不开」——所以不存在两个码同时开着的情况。
assert.equal(toggleQr('10.0.0.2', '10.0.0.3'), '10.0.0.3')
})
test('showsQrButton:首条不给按钮(它本来就有常驻码),其余都给', () => {
assert.equal(showsQrButton(0), false)
assert.equal(showsQrButton(1), true)
assert.equal(showsQrButton(9), true)
})
test('按钮:只有非首条出现,绑了点击,带 aria-expanded 与中文 title', () => {
const row = rowBlock()
assert.notEqual(row, '')
assert.match(row, /v-if="showsQrButton\(index\)"/)
assert.match(row, /class="mx-btn mx-btn-ghost mx-btn-sm"/)
assert.match(row, /@click="toggleLanQr\(item\.ip\)"/)
assert.match(row, /:aria-expanded="openLanQrIp === item\.ip"/)
assert.match(row, /title="点击显示二维码(点空白处关闭)"/)
})
test('懒生成:模板里不调编码器,只渲染「当前打开的那一条」', () => {
const row = rowBlock()
// v-for 里直接调 qrToSvg / encodeQr = 为每条地址都生成一个码,首屏会被几百个 rect 拖慢。
assert.doesNotMatch(row, /qrToSvg\(/)
assert.doesNotMatch(row, /encodeQr\(/)
assert.match(row, /v-if="openLanQrIp === item\.ip && openLanQrSvg !== null"/)
// 全页只有一处调 qrToSvg(首条常驻码与按需码共用的那个 helper),没有第二处在渲染时算码。
assert.equal((PAGE.match(/qrToSvg\(/g) ?? []).length, 1)
})
test('懒生成的 computed:没打开时一个码都不算', () => {
const openQr = /const openLanQrSvg = computed\(\(\) => \{[\s\S]*?\n\}\)/.exec(PAGE)?.[0] ?? ''
assert.notEqual(openQr, '', '找不到 openLanQrSvg')
assert.match(openQr, /if \(ip === null\) return null/)
assert.match(openQr, /find\(\(item\) => item\.ip === ip\)/)
// 那条地址被删掉(手填地址可以删)时也算不出码:不会渲染一个已经不在列表里的地址。
assert.match(openQr, /if \(link === undefined\) return null/)
})
test('关闭方式一:再点一次按钮=收起(走纯函数 toggleQr)', () => {
const toggle = /function toggleLanQr[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
assert.notEqual(toggle, '', '找不到 toggleLanQr')
assert.match(toggle, /toggleQr\(openLanQrIp\.value, ip\)/)
})
test('关闭方式二:Esc 挂在 document 上(不依赖按钮是否持有焦点)', () => {
const handler = /function onLanQrDismissKeydown[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
assert.notEqual(handler, '', '找不到 onLanQrDismissKeydown')
assert.match(handler, /event\.key === 'Escape'/)
assert.match(handler, /openLanQrIp\.value = null/)
assert.match(PAGE, /document\.addEventListener\('keydown', onLanQrDismissKeydown\)/)
})
test('关闭方式三:点空白处(document 上的 click,且排除自己的按钮与二维码容器)', () => {
// 必须是 click(而不是 pointerdown / mousedown):见页面里的注释——按下的那一刻收码会让卡片
// 当场变矮,码下方那些按钮在按住期间往上跳,浏览器就不会再派发 click,表现为「点了没反应」。
assert.match(PAGE, /document\.addEventListener\('click', onLanQrDismissClick\)/)
assert.doesNotMatch(PAGE, /addEventListener\('(pointerdown|mousedown|mouseup|touchstart)'/)
const handler = /function onLanQrDismissClick[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
assert.notEqual(handler, '', '找不到 onLanQrDismissClick')
assert.match(handler, /isLanQrKeepTarget\(target\)/)
assert.match(handler, /openLanQrIp\.value = null/)
// 按钮与二维码容器都要带记号:按钮自己负责开 / 关,容器内点击(长按识别、选中地址)也不算
// 「点空白处」。少了按钮上那个,document 会先把它关掉、按钮的处理函数再开一次。
// 注释里的解释文字不算数,先剥掉注释再数。
const row = rowBlock().replace(/<!--[\s\S]*?-->/g, '')
assert.equal((row.match(/data-lan-qr-keep/g) ?? []).length, 2, '按钮与二维码容器各一处')
assert.match(row, /class="lan-qr" data-lan-qr-keep/)
assert.match(row, /type="button"\s+data-lan-qr-keep/)
// 模板上的记号必须与判断里用的选择器是同一个名字:只改一边(比如把属性改名)会让排除
// 静默失效,而失效的表现是「按钮点了关不掉」,很难从代码上看出来。
const marker = LAN_QR_KEEP_SELECTOR.slice(1, -1)
assert.ok(marker.startsWith('data-'), `记号应当是一个 data 属性:${LAN_QR_KEEP_SELECTOR}`)
assert.ok(row.includes(marker), `模板里找不到 ${LAN_QR_KEEP_SELECTOR}`)
})
test('点空白处的排除判断:带记号的按钮 / 容器不放行,真空白处放行', () => {
// closest 是 DOM 接口,node 里没有;按结构注入假的即可把两条路都跑一遍。
const keep = { closest: (selector: string) => (selector === LAN_QR_KEEP_SELECTOR ? {} : null) }
assert.equal(isLanQrKeepTarget(keep), true)
// 空白处:closest 一路走到头都是 null(点的是卡片背景、别的卡片、页面空白)。
assert.equal(isLanQrKeepTarget({ closest: () => null }), false)
// target 为 null(事件没有目标)时按「不是自己的地盘」处理:该关就关——不能因为类型对不上
// 就让码留在屏幕上关不掉。
assert.equal(isLanQrKeepTarget(null), false)
})
test('监听按需挂、有挂就有摘(关掉时摘 + 组件卸载兜底摘)', () => {
assert.match(PAGE, /document\.removeEventListener\('click', onLanQrDismissClick\)/)
assert.match(PAGE, /document\.removeEventListener\('keydown', onLanQrDismissKeydown\)/)
// 挂 / 摘都由「码真的在屏幕上」驱动:点空白处、Esc、再点按钮、那条地址被删,任何一条关闭
// 路径都不必各自记得摘。
assert.match(PAGE, /watch\(\s*\(\) => openLanQrSvg\.value !== null,/)
const unmount = /onBeforeUnmount\(\(\) => \{[\s\S]*?\n\}\)/.exec(PAGE)?.[0] ?? ''
assert.match(unmount, /detachLanQrDismiss\(\)/)
})
test('首条那条常驻二维码原样保留', () => {
// 判据、位置、编码参数、那句话都没变:改的只是「其余各条怎么办」。
assert.match(PAGE, /v-if="index === 0 && lanQrSvg !== null" class="lan-qr"/)
assert.match(PAGE, /<div class="lan-qr-note">手机扫码直接打开<\/div>/)
assert.match(PAGE, /qrToSvg\(url, \{ scale, margin: QR_MARGIN, ecc: 'M' \}\)/)
const first = /const lanQrSvg = computed\(\(\) => \{[\s\S]*?\n\}\)/.exec(PAGE)?.[0] ?? ''
assert.match(first, /lanLinks\.value\[0\]/)
})
+126 -23
View File
@@ -2,8 +2,16 @@ import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { import {
FINAL_OPTION_TITLE,
FINAL_PUBLISH_LABEL, FINAL_PUBLISH_LABEL,
PREVIEW_RANK_ONLY_HINT,
PUBLISH_BUTTON_TITLE,
PUBLISH_OPTIONS,
RANK_ONLY_OPTION_TITLE,
boardModeNote, boardModeNote,
defaultPublishChoice,
openPublishChoice,
publishChoiceIsFinal,
publishModeConfirm, publishModeConfirm,
publishModeHint, publishModeHint,
rankOnlyOf, rankOnlyOf,
@@ -11,22 +19,77 @@ import {
} from '../pages/entryPublish' } from '../pages/entryPublish'
/** /**
* 「以决赛成绩发布」勾选项的口径(主人的新能力): * 发布口径(只更新名次 / 按决赛成绩发布)。
* 不勾(默认)= 本次发布只更新名次,不计分、不发奖牌; * 本次改动:**录入页把常驻勾选框换成了「点发布 → 弹窗里二选一」**:
* 勾上 = 与改动前完全一样(算分、发牌、上榜)。 * 只更新名次(默认)= 本次发布只更新名次,不计分、不发奖牌;
* 按决赛成绩发布 = 与改动前完全一样(算分、发牌、上榜)。
* *
* 两件事必须钉住:勾选状态真的进了请求(rankOnly),以及界面上任一处都不许把 * 三件事必须钉住:弹窗里选的那一项真的进了请求(rankOnly)、**每次打开弹窗都回到默认的
* 「只更新名次」说成会算分(确认框 / 常驻提示 / 预览面板)。 * 「只更新名次」**(不沿用上一次的选择),以及界面上任一处都不许把「只更新名次」说成会算分
* (弹窗选项 / 确认框正文 / 在榜口径提示 / 预览面板)。
*/ */
const SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') const SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const MODULE = readFileSync(new URL('../pages/entryPublish.ts', import.meta.url), 'utf8') const DIALOG = readFileSync(new URL('../components/ConfirmDialog.vue', import.meta.url), 'utf8')
test('默认不勾:勾选状态取反就是请求里的 rankOnly', () => { test('选项 → 请求字段:只有「按决赛成绩发布」才计分', () => {
assert.equal(rankOnlyOf(publishChoiceIsFinal('rank-only')), true, '只更新名次 = 只写名次')
assert.equal(rankOnlyOf(publishChoiceIsFinal('final')), false, '按决赛成绩发布 = 计分')
// 这一层的布尔口径本身不能变:审核页的常驻勾选框还在用它。
assert.equal(rankOnlyOf(false), true, '不勾 = 只更新名次') assert.equal(rankOnlyOf(false), true, '不勾 = 只更新名次')
assert.equal(rankOnlyOf(true), false, '勾上 = 计分发布') assert.equal(rankOnlyOf(true), false, '勾上 = 计分发布')
}) })
test('默认选中「只更新名次」,重开弹窗也不吃上一次的选择', () => {
assert.equal(defaultPublishChoice(), 'rank-only')
assert.equal(openPublishChoice(null), 'rank-only')
assert.equal(openPublishChoice('final'), 'rank-only', '上一次选过决赛也不能沿用')
assert.equal(openPublishChoice('rank-only'), 'rank-only')
})
test('两个选项的标题与说明都不是空串,且互不相同', () => {
assert.equal(PUBLISH_OPTIONS.length, 2)
assert.deepEqual(
PUBLISH_OPTIONS.map((option) => option.value),
['rank-only', 'final'],
'默认项在前',
)
for (const option of PUBLISH_OPTIONS) {
assert.notEqual(option.title.trim(), '', `${option.value} 的标题不能是空串`)
assert.notEqual(option.hint.trim(), '', `${option.value} 的说明不能是空串`)
}
assert.notEqual(PUBLISH_OPTIONS[0]?.title, PUBLISH_OPTIONS[1]?.title, '两个标题不能一样')
assert.notEqual(PUBLISH_OPTIONS[0]?.hint, PUBLISH_OPTIONS[1]?.hint, '两段说明不能一样')
// 原文就是主人点名的那两句。
assert.equal(RANK_ONLY_OPTION_TITLE, '只更新名次(不计分、不发奖牌)')
assert.equal(FINAL_OPTION_TITLE, '按决赛成绩发布(重算名次并计分、发奖牌、榜单跟着更新)')
// 说明复用现成的口径函数,不另写一套说法。
assert.equal(PUBLISH_OPTIONS[0]?.hint, publishModeHint(false))
assert.equal(PUBLISH_OPTIONS[1]?.hint, publishModeHint(true))
// 默认标记只有一个,且必须落在「只更新名次」那一项上。
assert.deepEqual(
PUBLISH_OPTIONS.filter((option) => option.badge !== '').map((option) => option.value),
['rank-only'],
)
})
test('录入页可见的文案里不再出现「勾选」——那是指向已删除的勾选框', () => {
// 审核页仍是常驻勾选框,FINAL_PUBLISH_LABEL / FINAL_PUBLISH_TITLE 归它用,不在此列。
const entryFacing = [
RANK_ONLY_OPTION_TITLE,
FINAL_OPTION_TITLE,
PUBLISH_BUTTON_TITLE,
PREVIEW_RANK_ONLY_HINT,
publishModeHint(false),
publishModeHint(true),
publishModeConfirm(false, 0),
publishModeConfirm(true, 0),
boardModeNote(3, 0) ?? '',
boardModeNote(3, 18) ?? '',
]
for (const text of entryFacing) assert.doesNotMatch(text, /勾选/, text)
})
test('确认框写明本次是计分发布还是只更新名次', () => { test('确认框写明本次是计分发布还是只更新名次', () => {
const scored = publishModeConfirm(true, 0) const scored = publishModeConfirm(true, 0)
assert.match(scored, /按决赛成绩发布/) assert.match(scored, /按决赛成绩发布/)
@@ -50,13 +113,13 @@ test('只更新名次时,本组已有在榜积分必须点名说清「不会
assert.doesNotMatch(publishModeConfirm(true, 18), /保留/) assert.doesNotMatch(publishModeConfirm(true, 18), /保留/)
}) })
test('勾选框旁的常驻提示:不勾时明确说「不计分」', () => { test('两种口径的说明:只更新名次时明确说「不计分」', () => {
assert.match(publishModeHint(false), /只更新名次/) assert.match(publishModeHint(false), /只更新名次/)
assert.match(publishModeHint(false), /不计分/) assert.match(publishModeHint(false), /不计分/)
assert.match(publishModeHint(true), /算分/) assert.match(publishModeHint(true), /算分/)
}) })
test('勾选框文案本身就说清「勾选才计分发牌」', () => { test('审核页的常驻勾选框文案不变:仍说清「勾选才计分发牌」', () => {
assert.match(FINAL_PUBLISH_LABEL, /决赛/) assert.match(FINAL_PUBLISH_LABEL, /决赛/)
assert.match(FINAL_PUBLISH_LABEL, /勾选才计分/) assert.match(FINAL_PUBLISH_LABEL, /勾选才计分/)
}) })
@@ -85,28 +148,68 @@ test('在榜积分只数已发布的成绩,草稿不算', () => {
) )
}) })
test('录入页:勾选框默认不勾,且勾选状态进了发布请求', () => { /**
assert.match(SOURCE, /const finalPublish = ref\(false\)/) * 下面这几条是**源码级接线断言**:`.vue` 跑不进 `node --test`,而「勾选框撤了、弹窗没接上」
assert.match(SOURCE, /v-model="finalPublish"/) * 这种改到一半的回归没有任何测试拦得住 —— 它既不是类型错误,也不影响构建。
assert.match(SOURCE, /rankOnly: rankOnlyOf\(finalPublish\.value\)/) */
// 发布请求里不能写死 false(写死 = 永远计分发布,勾选框变成摆设)。 const TOOLBAR = SOURCE.slice(SOURCE.indexOf('<template #actions>'), SOURCE.indexOf('</PageHeader>'))
assert.doesNotMatch(SOURCE, /rankOnly: false/) const PUBLISH_DIALOG = SOURCE.slice(
SOURCE.indexOf('<ConfirmDialog\n :open="publishDialog"'),
SOURCE.indexOf('</ConfirmDialog>', SOURCE.indexOf(':open="publishDialog"')),
)
test('录入页:工具栏上已经没有那个绑 finalPublish 的 input[type=checkbox]', () => {
assert.notEqual(TOOLBAR.length, 0, '工具栏那一段得能切出来')
assert.doesNotMatch(TOOLBAR, /type="checkbox"/)
assert.doesNotMatch(TOOLBAR, /finalPublish/)
assert.doesNotMatch(TOOLBAR, /publishChoice/)
// 撤掉的是勾选框与它旁边的徽标,发布按钮本身还在、还走同一个入口。
assert.doesNotMatch(SOURCE, /v-model="finalPublish"/)
assert.doesNotMatch(SOURCE, /publish-final-toggle/)
assert.doesNotMatch(SOURCE, /FINAL_PUBLISH_LABEL/)
assert.doesNotMatch(SOURCE, /FINAL_PUBLISH_TITLE/)
assert.doesNotMatch(SOURCE, /publishModeBadge/)
assert.match(TOOLBAR, /@click="requestPublish"/)
assert.match(TOOLBAR, /PUBLISH_BUTTON_TITLE/)
}) })
test('录入页:勾选框与醒目提示就贴在发布按钮边上', () => { test('录入页:两个选项都挂在那个发布确认框里(不是第二个弹窗)', () => {
assert.match(SOURCE, /class="publish-final-toggle"/) assert.notEqual(PUBLISH_DIALOG.length, 0, '确认框那一段得能切出来')
assert.match(SOURCE, /publishModeHint\(finalPublish\)/) assert.match(PUBLISH_DIALOG, /v-for="option in PUBLISH_OPTIONS"/)
// 不勾时必须给出可读的「只更新名次」标记(警示色),不能只靠勾选框本身。 assert.match(PUBLISH_DIALOG, /v-model="publishChoice"/)
assert.match(SOURCE, /publishModeBadge/) assert.match(PUBLISH_DIALOG, /type="radio"/)
assert.match(PUBLISH_DIALOG, /:value="option\.value"/)
assert.match(PUBLISH_DIALOG, /option\.title/)
assert.match(PUBLISH_DIALOG, /option\.hint/)
// 取消 = 什么都不发生(与改动前一致);确认才发。
assert.match(PUBLISH_DIALOG, /confirm-text="确认发布"/)
assert.match(PUBLISH_DIALOG, /@cancel="publishDialog = false"/)
// 挂得进去是因为 ConfirmDialog 开了默认插槽。
assert.match(DIALOG, /<slot><\/slot>/)
})
test('录入页:默认「只更新名次」,选中的口径真的进了发布请求', () => {
assert.match(SOURCE, /const publishChoice = ref<PublishChoice>\(defaultPublishChoice\(\)\)/)
assert.match(SOURCE, /const finalPublish = computed\(\(\) => publishChoiceIsFinal\(publishChoice\.value\)\)/)
assert.match(SOURCE, /publishChoice\.value = openPublishChoice\(publishChoice\.value\)/)
// 弹窗开着时再按一次 Alt+Enter 不能把选好的口径复位掉。
assert.match(SOURCE, /if \(publishDialog\.value\) return/)
assert.match(SOURCE, /rankOnly: rankOnlyOf\(finalPublish\.value\)/)
// 发布请求里不能写死 false(写死 = 永远计分发布,选项变成摆设)。
assert.doesNotMatch(SOURCE, /rankOnly: false/)
}) })
test('录入页:确认框用同一套文案,且把在榜积分传进去', () => { test('录入页:确认框用同一套文案,且把在榜积分传进去', () => {
assert.match(SOURCE, /publishModeConfirm\(finalPublish\.value, pointsOnBoard\.value\)/) assert.match(SOURCE, /publishModeConfirm\(finalPublish\.value, pointsOnBoard\.value\)/)
assert.match(SOURCE, /const publishDialogTitle = computed/)
}) })
test('录入页:实时积分预览要说明「不勾时这些分差不会发生」', () => { test('录入页:实时积分预览要说明「发布时选只更新名次时这些分差不会发生」', () => {
assert.match(SOURCE, /preview-rank-only-hint/) // 口径要等点发布才选,所以这句常驻显示,不能再挂在某个勾选状态上。
assert.match(MODULE, /勾选「以决赛成绩发布」/) const line = /<p\b[^>]*preview-rank-only-hint[^>]*>/.exec(SOURCE)?.[0] ?? ''
assert.match(line, /preview-hint/)
assert.doesNotMatch(line, /v-if/)
assert.match(PREVIEW_RANK_ONLY_HINT, /发布时在弹出的窗口里选「只更新名次」/)
}) })
test('发布沿用旧路径:请求体仍带 eventId / categoryId / requestId', () => { test('发布沿用旧路径:请求体仍带 eventId / categoryId / requestId', () => {
@@ -55,8 +55,10 @@ const eventNames = computed(() => new Map(events.value.map((event) => [event.id,
/** /**
* 「当前项目」由服务端判定(GET /api/display/live:人工指定 → 排期命中 → 最近发布)。 * 「当前项目」由服务端判定(GET /api/display/live:人工指定 → 排期命中 → 最近发布)。
* *
* 拉到过结论就用它(不受快照 20 行截断,auto/manual 都一样);还没成功过(老服务端、断网首屏、 * 拉到过结论就用它(这个接口按小项给**全量**的已发布成绩、**不设条数上限**,不受快照那个
* 示例数据)才退回「人工指定 + 快照分组」的兜底素材,有总比没有强。 * 「每组保底前 10 行、最多 8 组」窗口(`latestResultsGroupLimit` / `latestResultsRowsPerGroup`)限制,
* auto/manual 都一样);还没成功过(老服务端、断网首屏、示例数据)才退回「人工指定 + 快照分组」
* 的兜底素材,有总比没有强。
*/ */
const sources = computed<LiveEventSource[]>(() => const sources = computed<LiveEventSource[]>(() =>
liveFeed.value !== null liveFeed.value !== null
+383
View File
@@ -0,0 +1,383 @@
/**
* 大屏设备标签:把「这台机器是什么」压成一行,随 hello 上报给管理端列表。
*
* 现场问题:局域网浏览器打开 /display 时,管理端那一行只有服务端现生成的
* `display-a1b2c3d4`,操作员分不清哪台是二号楼的平板。这里用浏览器自己的 API 拼出
* `安卓 13 · Chrome 120 · 1080×2400 @3x · 手机` 这样一行,服务端只负责存与显示。
*
* 三个必须容错的地方(任一没兜住都会让标签变成 undefined 或干脆连不上):
* 1. `navigator.userAgentData` 只有 Chromium 系有,Safari / Firefox 只能解析 UA 字符串;
* 2. `getHighEntropyValues()` 是 Promise,**且要安全上下文**——内网 `http://192.168.x.x`
* 下会被直接拒绝,所以它只能用来「能拿到就锦上添花」,拿不到必须静默退回 UA 解析;
* 3. 测试环境里没有 `screen` / `navigator`,各段缺失时要整段跳过,不能出现空段。
*
* 纯逻辑,不依赖任何浏览器全局对象(`deviceLabelSource()` 是唯一的取值入口,注入即可测)。
*/
/** 标签最多这么多个字符(按码点算,不是 UTF-16 码元):管理端一行放不下更长的。 */
export const DEVICE_LABEL_MAX = 80
/** 高熵值只问这三项:型号、系统版本、完整浏览器版本;其余(架构等)对我们没用。 */
export const HIGH_ENTROPY_HINTS = ['model', 'platformVersion', 'uaFullVersion'] as const
/** 低熵上报的品牌项(`userAgentData.brands`)。 */
export interface UserAgentBrand {
brand: string
version: string
}
/** 高熵值(`getHighEntropyValues`)里我们会用到的几项。 */
export interface HighEntropyValues {
platform?: string
platformVersion?: string
uaFullVersion?: string
model?: string
}
/**
* `navigator.userAgentData` 的最小形状。
*
* 注意 `platformVersion` 在 Chrome 上属于**高熵**值(低熵只有 brands / mobile / platform),
* 内网 http 下拿不到——所以同步路径必须有 UA 解析兜底,异步路径再用它把
* 「UA 归约后的假系统版本」(Chrome 把所有 Android 10+ 都写成 `Android 10`)修正过来。
*/
export interface UserAgentDataLike {
platform?: string
platformVersion?: string
mobile?: boolean
brands?: UserAgentBrand[]
getHighEntropyValues?: (hints: string[]) => Promise<HighEntropyValues>
}
export interface ScreenLike {
width?: number
height?: number
}
/** 拼标签要用到的全部外部事实;全部可选,缺了就跳过对应那一段。 */
export interface DeviceLabelSource {
userAgent?: string
userAgentData?: UserAgentDataLike | null
screen?: ScreenLike | null
devicePixelRatio?: number
maxTouchPoints?: number
}
/** 一短一长两份输出:短的是列表主行,长的是原始 UA(悬浮提示 / 排障用)。 */
export interface DeviceLabel {
device: string
deviceRaw: string
}
/** 一段一段的标签;空串表示这一段没拿到,拼装时跳过。 */
export interface DeviceFields {
system: string
browser: string
screen: string
kind: string
}
export interface UserAgentParse {
system: string
kind: string
browser: string
}
/**
* 当前浏览器的事实来源。
*
* 整段包 try:某些隐私模式 / 嵌入 webview 里读 `navigator.userAgentData` 本身就可能抛,
* 而设备标签只是锦上添花,绝不能因此让大屏连不上。
*/
export function deviceLabelSource(): DeviceLabelSource {
try {
const nav = (globalThis as { navigator?: Navigator & { userAgentData?: UserAgentDataLike } })
.navigator
const screenLike = (globalThis as { screen?: ScreenLike & { width?: number } }).screen
if (nav === undefined) return {}
return {
userAgent: nav.userAgent,
userAgentData: nav.userAgentData ?? null,
screen: screenLike ?? null,
devicePixelRatio: (globalThis as { devicePixelRatio?: number }).devicePixelRatio,
maxTouchPoints: nav.maxTouchPoints,
}
} catch {
return {}
}
}
/** Windows NT 版本号 → 现场认得的名字;认不出(未来的 NT)就原样写版本号。 */
const WINDOWS_NT: Record<string, string> = {
'5.0': '2000',
'5.1': 'XP',
'5.2': 'XP',
'6.0': 'Vista',
'6.1': '7',
'6.2': '8',
'6.3': '8.1',
'10.0': '10',
'11.0': '11',
}
interface SystemGuess {
label: string
/** 这一段带具体版本号(`安卓 13`)还是只有名字(`macOS`);决定要不要被高熵值顶替。 */
versioned: boolean
/** UA 里的版本号被浏览器**故意归约**过(Chrome 把所有 Android 10+ 都写成 `Android 10`)。 */
reduced?: boolean
/**
* UA 里的这个名字比 UA-CH 更具体,不许被顶替。
*
* 目前只有 HarmonyOS:它的 UA 里同时写着 Android,而 UA-CH 的 platform 只会说 `Android`
* ——让 UA-CH 顶上去等于把华为设备降级成「安卓」。
*/
authoritative?: boolean
}
/**
* 只按 UA 字符串解析系统 / 形态 / 浏览器。
*
* iPadOS 13+ 的 UA 伪装成 macOS 桌面版(`Macintosh; Intel Mac OS X`),唯一的破绽是
* `maxTouchPoints > 1`(桌面 Mac 没有多点触摸),所以 isIpad 由调用方先算好传进来。
*/
export function parseUserAgent(
userAgent: string,
options: { maxTouchPoints?: number } = {},
): UserAgentParse {
const isIpad = isIpadUserAgent(userAgent, options.maxTouchPoints)
return {
system: uaSystem(userAgent, isIpad).label,
kind: kindLabel(userAgent, null, isIpad),
browser: browserLabel(userAgent, null),
}
}
/** 拼装前的四段:先取 UA(覆盖最广),再让 UA-CH 的可用信息顶替。 */
export function deviceFields(source: DeviceLabelSource): DeviceFields {
const userAgent = source.userAgent ?? ''
const userAgentData = source.userAgentData ?? null
const isIpad = isIpadUserAgent(userAgent, source.maxTouchPoints)
return {
system: systemLabel(userAgent, userAgentData, isIpad),
browser: browserLabel(userAgent, userAgentData),
screen: screenLabel(source),
kind: kindLabel(userAgent, userAgentData, isIpad),
}
}
/** 拼成一行:段序 系统 · 浏览器 · 分辨率 · 形态;空段直接跳过,不留 `undefined` 或双分隔符。 */
export function composeDeviceLabel(fields: DeviceFields, deviceRaw = ''): DeviceLabel {
const parts = [fields.system, fields.browser, fields.screen, fields.kind].filter(
(part) => part !== '',
)
return { device: truncateDeviceLabel(parts.join(' · ')), deviceRaw }
}
/** 同步版本:低熵 UA-CH + UA 解析。列表主行要立刻有名字,不能等异步。 */
export function deviceLabel(source: DeviceLabelSource): DeviceLabel {
return composeDeviceLabel(deviceFields(source), source.userAgent ?? '')
}
/**
* 异步版本:再问一次高熵值,拿到更准的系统 / 浏览器版本后重算。
*
* 只有 Chromium 系 + **安全上下文**(https 或 localhost)才拿得到;内网 http 会 reject,
* 方法不存在(Safari / Firefox)直接跳过——两种情况都退回同步结果,绝不能抛出去打断调用方。
* 差值通常就是 Android:UA 被归约成固定的 `Android 10`,真实版本只在 platformVersion 里。
*/
export async function resolveDeviceLabel(source: DeviceLabelSource): Promise<DeviceLabel> {
const base = deviceLabel(source)
const userAgentData = source.userAgentData ?? null
if (typeof userAgentData?.getHighEntropyValues !== 'function') return base
let values: HighEntropyValues
try {
values = await userAgentData.getHighEntropyValues([...HIGH_ENTROPY_HINTS])
} catch {
// 非安全上下文 / 用户拒绝:高熵值拿不到是常态,不是错误。
return base
}
if (values === null || typeof values !== 'object') return base
const merged: UserAgentDataLike = {
...userAgentData,
platform: values.platform ?? userAgentData.platform,
platformVersion: values.platformVersion ?? userAgentData.platformVersion,
}
return deviceLabel({ ...source, userAgentData: merged })
}
/** 超长截断:按**码点**切,`Array.from` 不会把代理对劈成半个字符(emoji 在 UA 里很常见)。 */
export function truncateDeviceLabel(value: string): string {
const chars = Array.from(value)
if (chars.length <= DEVICE_LABEL_MAX) return value
// 截断处补一个省略号,让管理端一眼看出「这名字是被截过的」,同时仍不超过上限。
return `${chars.slice(0, DEVICE_LABEL_MAX - 1).join('')}…`
}
function isIpadUserAgent(userAgent: string, maxTouchPoints?: number): boolean {
if (/iPad/i.test(userAgent)) return true
// iPadOS 13+ 的桌面版 UA:写着 Macintosh,但触摸点数暴露了它是触屏设备。
return /Macintosh/i.test(userAgent) && (maxTouchPoints ?? 0) > 1
}
function systemLabel(
userAgent: string,
userAgentData: UserAgentDataLike | null,
isIpad: boolean,
): string {
const fromUa = uaSystem(userAgent, isIpad)
const fromHints = hintSystem(userAgentData)
if (fromHints.label === '') return fromUa.label
if (fromUa.label === '') return fromHints.label
// UA-CH 带版本号,而 UA 没有版本号、或 UA 那一版**被浏览器归约过**(只有 Android 会)时以它为准:
// Chrome 把 Android 10+ 的 UA 一律写成 `Android 10`,真实系统版本只在 platformVersion 里。
// 反过来 Safari / Firefox 没有 UA-CH,只能靠 UA;Windows / macOS 的 UA-CH 也给不出更细的版本;
// HarmonyOS 是 UA 独有的名字(UA-CH 只会说 Android),标了 authoritative,不参与顶替。
if (fromUa.authoritative === true) return fromUa.label
if (fromHints.versioned && (fromUa.reduced === true || !fromUa.versioned)) return fromHints.label
return fromUa.label
}
function uaSystem(userAgent: string, isIpad: boolean): SystemGuess {
if (isIpad) {
const os = /OS (\d+)[._]/.exec(userAgent)
const version = os === null ? '' : majorVersion(os[1])
// 伪装成 macOS 的 iPadOS 拿不到系统版本(UA 里的 Version/ 是 Safari 的版本),
// 这时只说系统名,不编一个版本号出来。
return version === '' ? plain('iPadOS') : versioned(`iPadOS ${version}`)
}
// HarmonyOS 的 UA 里同时写着 Android,必须先认它,否则会被说成安卓;也不许被 UA-CH 顶替
//(UA-CH 只会说这台机器是 Android,比 UA 更粗)。
const harmony = /HarmonyOS(?:[ /](\d+(?:\.\d+)*))?/i.exec(userAgent)
if (harmony !== null) {
const version = harmony[1] === undefined ? '' : majorVersion(harmony[1])
if (version === '') return { label: 'HarmonyOS', versioned: false, authoritative: true }
return { label: `HarmonyOS ${version}`, versioned: true, authoritative: true }
}
const android = /Android[ /](\d+(?:\.\d+)*)/i.exec(userAgent)
if (android !== null) {
const version = majorVersion(android[1])
// Chrome 的 UA 归约:Android 10 及以上的机器在 UA 里都写着 `Android 10`(见 systemLabel 的取舍)。
return version === ''
? { label: '安卓', versioned: false, reduced: true }
: { label: `安卓 ${version}`, versioned: true, reduced: true }
}
const apple = /(?:iPhone|iPod)[^)]*\bOS (\d+)[._](\d+)/i.exec(userAgent)
if (apple !== null) return versioned(`iOS ${apple[1]}`)
const windows = /Windows NT (\d+\.\d+)/.exec(userAgent)
if (windows !== null) {
const name = WINDOWS_NT[windows[1]]
return versioned(name === undefined ? `Windows NT ${windows[1]}` : `Windows ${name}`)
}
// macOS 不带版本号:Safari 十几年如一日地写着 10_15_7,写出来只会误导。
if (/Macintosh|Mac OS X/i.test(userAgent)) return plain('macOS')
if (/Linux|X11|CrOS/i.test(userAgent)) return plain('Linux')
return plain('')
}
function hintSystem(userAgentData: UserAgentDataLike | null): SystemGuess {
const platform = typeof userAgentData?.platform === 'string' ? userAgentData.platform.trim() : ''
if (platform === '') return plain('')
const version = majorVersion(
typeof userAgentData?.platformVersion === 'string' ? userAgentData.platformVersion : '',
)
if (platform === 'Android') return version === '' ? plain('安卓') : versioned(`安卓 ${version}`)
if (platform === 'iOS') return version === '' ? plain('iOS') : versioned(`iOS ${version}`)
if (platform === 'Chrome OS') return plain('ChromeOS')
// macOS / Windows / Linux 等名字本身就能看懂,直接用它。
return plain(platform)
}
function kindLabel(
userAgent: string,
userAgentData: UserAgentDataLike | null,
isIpad: boolean,
): string {
if (isIpad) return '平板'
const mobile = typeof userAgentData?.mobile === 'boolean' ? userAgentData.mobile : null
if (/Android/i.test(userAgent)) {
// Android 平板的两种写法:显式 Tablet,或者干脆不带 Mobile(手机版一定带)。
// UA-CH 的 mobile 是同一件事的权威说法,与 UA 冲突时以它为准(UA 可能被改写过)。
if (mobile === false) return '平板'
return /Tablet/i.test(userAgent) || !/Mobile/i.test(userAgent) ? '平板' : '手机'
}
if (/iPhone|iPod/i.test(userAgent)) return '手机'
// UA 认不出形态(桌面浏览器)时还能问一句 UA-CH;两者都没有就什么都不说,不猜。
if (mobile === true) return '手机'
if (userAgent !== '' || mobile === false) return '桌面'
return ''
}
function browserLabel(userAgent: string, userAgentData: UserAgentDataLike | null): string {
// 顺序即优先级:微信、UC 的 UA 里同时写着 Chrome / Safari,必须先认出来;
// Edge 的 UA 里同时写着 Chrome,Chrome 的 UA 里同时写着 Safari。
const wechat = /MicroMessenger[/ ](\d+(?:\.\d+)*)/i.exec(userAgent)
if (wechat !== null) return nameWithVersion('微信', wechat[1])
const uc = /UCBrowser[/ ](\d+(?:\.\d+)*)/i.exec(userAgent)
if (uc !== null) return nameWithVersion('UC', uc[1])
const edge = /Edg[a-z]*[/ ]v?(\d+(?:\.\d+)*)/i.exec(userAgent)
if (edge !== null) return nameWithVersion('Edge', edge[1])
const chrome = /(?:Chrome|CriOS)[/ ](\d+(?:\.\d+)*)/i.exec(userAgent)
if (chrome !== null) return nameWithVersion('Chrome', chrome[1])
const firefox = /(?:Firefox|FxiOS)[/ ](\d+(?:\.\d+)*)/i.exec(userAgent)
if (firefox !== null) return nameWithVersion('Firefox', firefox[1])
const safari = /Version[/ ](\d+(?:\.\d+)*)[\s\S]*Safari/i.exec(userAgent)
if (safari !== null) return nameWithVersion('Safari', safari[1])
if (/Safari/i.test(userAgent)) return 'Safari'
return brandLabel(userAgentData)
}
/** UA 里认不出浏览器时,退回低熵的 `brands`(Chromium 一定带)。 */
function brandLabel(userAgentData: UserAgentDataLike | null): string {
const brands = Array.isArray(userAgentData?.brands) ? userAgentData.brands : []
// 「Not_A Brand」是规范要求的占位品牌,Chromium 是同一内核的马甲,都排在真名之后。
const usable = brands.filter((item) => !/Not.?A.?Brand/i.test(item.brand))
const real = usable.filter((item) => !/^Chromium$/i.test(item.brand))
const picked = (real.length > 0 ? real : usable)[0]
if (picked === undefined) return ''
const name = /^Google Chrome$/i.test(picked.brand) ? 'Chrome' : picked.brand
return nameWithVersion(name, picked.version)
}
function screenLabel(source: DeviceLabelSource): string {
const width = positiveInt(source.screen?.width)
const height = positiveInt(source.screen?.height)
if (width === null || height === null) return ''
const dpr = source.devicePixelRatio
// 倍率 ≤1(桌面常见的 1x、缩放后的 0.9x)不写:每台机器都拖一个没信息量的后缀。
if (typeof dpr !== 'number' || !Number.isFinite(dpr) || dpr <= 1) return `${width}×${height}`
return `${width}×${height} @${trimNumber(dpr)}x`
}
function positiveInt(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
return Math.round(value)
}
/** 保留两位小数并去掉尾随 0:安卓报的 2.625 写成 `2.63x`,整数倍率写成 `3x`。 */
function trimNumber(value: number): string {
return String(Math.round(value * 100) / 100)
}
/** 取主版本号;`0` / 取不到都按「没有版本」处理,免得写出 `安卓 0`。 */
function majorVersion(value: string): string {
const match = /^(\d+)/.exec(value.trim())
if (match === null) return ''
const parsed = Number.parseInt(match[1] ?? '', 10)
return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : ''
}
function nameWithVersion(name: string, version: string): string {
const major = majorVersion(version)
return major === '' ? name : `${name} ${major}`
}
function plain(label: string): SystemGuess {
return { label, versioned: false }
}
function versioned(label: string): SystemGuess {
return { label, versioned: true }
}
+136
View File
@@ -0,0 +1,136 @@
/**
* 局域网大屏的**稳定实例 id**。
*
* 修的 bug:局域网浏览器打开 `/display` 时 URL 里没有 `instance` / `screen`,于是 hello
* 的 `displayKey` / `instanceId` 都是空的,服务端每收到一次 hello 就现生成一个
* `display-<随机>`;而 `RealtimeClient.setPage()` 在翻页后会**再发一次 hello** —— 老连接那条
* 实例就永远留在注册表里变成「离线」条目(管理端列表里凭空多出一条该设备的离线记录,
* TTL 十分钟)。让 id 在本机稳定下来,重发 hello 就只是刷新同一条记录。
*
* 取舍(主人已确认,不做额外复杂度):**同一个浏览器开两个标签页会合并成一个条目**,
* 后上报的那页覆盖前一个的页面 / 心跳。现场一块屏就是一个标签页,不值得为它引入 session 级
* 存储或 BroadcastChannel。
*
* 另一条底线:localStorage 不可用(隐私模式、配额满、属性读取本身抛异常)时**静默退回今天的
* 行为**(交给服务端生成),绝不能因为存储问题让大屏连不上。
*/
/** 本机实例 id 的前缀:服务端一眼能看出这不是 Console 带来的 id。 */
export const DISPLAY_INSTANCE_ID_PREFIX = 'browser-'
/** 实例 id 在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */
export const DISPLAY_INSTANCE_STORAGE_KEY = 'scoreboard.display.instance'
/** 存下来的 id 最长这么多字符;超过一律当成脏数据重生成。 */
const MAX_STORED_ID_LENGTH = 64
/** 合法 id:我们自己生成的、或 Console 那种主机名式 id 都落在 ASCII 可见字符里。 */
const STORED_ID_PATTERN = /^[A-Za-z0-9._:-]+$/
/** localStorage 的最小接口;注入假实现后,node 里也能把存取与脏数据这两条路走一遍。 */
export interface InstanceIdStorage {
getItem(key: string): string | null
setItem(key: string, value: string): void
}
/** 浏览器 localStorage;隐私模式下**读属性本身就抛**,所以整段包 try。 */
export function instanceIdStorage(): InstanceIdStorage | null {
try {
return (globalThis as { localStorage?: InstanceIdStorage }).localStorage ?? null
} catch {
return null
}
}
export interface DisplayUrlIdentity {
/** URL 里的 `instance`(Console 打开的窗口才有)。 */
instance?: string | null
/** URL 里的 `screen`(Console 带下来的屏号)。 */
screen?: string | null
}
/** hello 要用的三个值;`browserId` 只在局域网浏览器(没有 URL 参数)时存在。 */
export interface DisplayIdentity {
/** hello 的 displayKey:URL 里有就沿用 URL 的,否则用本机稳定 id。 */
displayKey: string | undefined
/**
* hello 的 instanceId。
*
* **只有** URL 带 `instance`(Console 窗口)或走本机稳定 id 时才上报:只带 `screen`
* 的窗口维持升级前的行为(服务端按 displayKey 认它),免得凭空多出一套认法。
*/
instanceId: string | undefined
/** 本机稳定 id(局域网浏览器才有)。 */
browserId: string | undefined
}
/**
* 解析实例 id:URL 参数优先,其次本机持久化的那个(没有就生成并写回)。
*
* 返回 undefined = 交给服务端生成(今天的行为)——存储不可用、或调用方压根没给 storage。
* 注意这里把空串当「没给」:`?instance=` 这种残缺链接以前会让 displayKey / instanceId 都变成
* 空串,服务端照样现生成一个(正是本文件要修的那个 bug)。
*/
export function resolveInstanceId(
url: DisplayUrlIdentity,
storage: InstanceIdStorage | null,
randomId: () => string = randomBrowserId,
): string | undefined {
const fromUrl = nonEmpty(url.instance) ?? nonEmpty(url.screen)
if (fromUrl !== undefined) return fromUrl
if (storage === null) return undefined
try {
const saved = normalizeStoredId(storage.getItem(DISPLAY_INSTANCE_STORAGE_KEY))
if (saved !== null) return saved
const created = `${DISPLAY_INSTANCE_ID_PREFIX}${randomId()}`
storage.setItem(DISPLAY_INSTANCE_STORAGE_KEY, created)
return created
} catch {
// 隐私模式:读或写抛异常都按「存不了」处理,退回服务端生成,绝不因此连不上。
return undefined
}
}
/** 拆出 hello 要的三个值;Console 窗口(URL 带 instance / screen)的行为与升级前完全一致。 */
export function resolveDisplayIdentity(
url: DisplayUrlIdentity,
storage: InstanceIdStorage | null,
randomId: () => string = randomBrowserId,
): DisplayIdentity {
const instance = nonEmpty(url.instance)
const screen = nonEmpty(url.screen)
if (instance !== undefined) {
return { displayKey: instance, instanceId: instance, browserId: undefined }
}
if (screen !== undefined) {
return { displayKey: screen, instanceId: undefined, browserId: undefined }
}
const browserId = resolveInstanceId(url, storage, randomId)
return { displayKey: browserId, instanceId: browserId, browserId }
}
/** 生成 `browser-3f2a1c9d` 这样的 id;crypto 拿不到就退回 Math.random(够用:只要本机不重复)。 */
export function randomBrowserId(): string {
const cryptoLike = (globalThis as { crypto?: Crypto }).crypto
if (typeof cryptoLike?.getRandomValues === 'function') {
const buffer = new Uint8Array(4)
cryptoLike.getRandomValues(buffer)
return Array.from(buffer, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
return Math.random().toString(16).slice(2, 10).padEnd(8, '0')
}
/** 空串 / 纯空白 / null 一律当成「没给」。 */
function nonEmpty(value: string | null | undefined): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
/** 存下来的值可能被手工改过、或是别的版本写的:认不出就当没有,重生成一个。 */
function normalizeStoredId(value: string | null): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (trimmed === '' || trimmed.length > MAX_STORED_ID_LENGTH) return null
return STORED_ID_PATTERN.test(trimmed) ? trimmed : null
}
+2 -1
View File
@@ -97,7 +97,8 @@ export function pickLiveColumns(events: readonly LiveEventSource[]): LiveColumn[
* 接口还没成功过时的兜底素材:人工指定的 id + 快照 latestResults 里能拿到的组。 * 接口还没成功过时的兜底素材:人工指定的 id + 快照 latestResults 里能拿到的组。
* *
* 只在老服务端(没有 /api/display/live)、断网首屏、示例数据这三种情况下走这条路; * 只在老服务端(没有 /api/display/live)、断网首屏、示例数据这三种情况下走这条路;
* 快照的 latestResults 受 20 行上限约束,所以它只能算「有总比没有强」。 * 快照的 latestResults 窗口是**每组保底前 10 行、最多 8 组**(`realtime/snapshot.go` 的
* `latestResultsGroupLimit` / `latestResultsRowsPerGroup`),所以它只能算「有总比没有强」。
* 快照里没有该项目时保留这一栏(groups 为空),由页面显示占位而不是静默少一栏。 * 快照里没有该项目时保留这一栏(groups 为空),由页面显示占位而不是静默少一栏。
*/ */
export function fallbackLiveSources( export function fallbackLiveSources(
+3 -2
View File
@@ -77,8 +77,9 @@ export interface EventResultColumn {
* 第 1 名不在先到的那 6 行里就整行不显示、剩下的名次看着是跳的(3、6、9、14、15、16)。 * 第 1 名不在先到的那 6 行里就整行不显示、剩下的名次看着是跳的(3、6、9、14、15、16)。
* 名次只能由名次自己说了算(实时赛况页的 liveRows 同一套口径)。 * 名次只能由名次自己说了算(实时赛况页的 liveRows 同一套口径)。
* *
* ⚠️ 服务端**根本没下发**的行补不回来:那是快照 20 行窗口(`realtime/snapshot.go` 的 * ⚠️ 服务端**根本没下发**的行补不回来:那是快照「每组保底前 10 行、最多 8 组」窗口
* `latestResultsLimit`)截断的问题,不在这层;这一层只保证「拿到的行按名次画」。 * (`realtime/snapshot.go` 的 `latestResultsGroupLimit` / `latestResultsRowsPerGroup`)截断的问题,
* 不在这层;这一层只保证「拿到的行按名次画」。
* *
* 并列名次保持原有先后(`sort` 在 ES2019 起是稳定的):服务端按发布时间给出的并列行 * 并列名次保持原有先后(`sort` 在 ES2019 起是稳定的):服务端按发布时间给出的并列行
* 不许在这里被换位置,否则前两栏的观感就变了。无名次(延迟结算)的行排最后。 * 不许在这里被换位置,否则前两栏的观感就变了。无名次(延迟结算)的行排最后。
+233
View File
@@ -0,0 +1,233 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
DEVICE_LABEL_MAX,
composeDeviceLabel,
deviceFields,
deviceLabel,
parseUserAgent,
resolveDeviceLabel,
truncateDeviceLabel,
type DeviceLabelSource,
type HighEntropyValues,
type UserAgentDataLike,
} from '../deviceLabel'
/**
* 设备标签:管理端「在线大屏」列表的主行。
*
* 现场问题是一串 `display-a1b2c3d4` 分不清哪台是哪台,所以这段解析必须**认得出现场那几种设备**
* (安卓手机、iPad、Windows 桌面、微信内置浏览器),并且在只有 UA、或只有低熵 UA-CH 的机器上
* 都要给出人话;`getHighEntropyValues` 在内网 http 下会被拒绝,那条路必须静默退回。
*/
const ANDROID_CHROME_UA =
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
const IPAD_SAFARI_UA =
'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1'
/** iPadOS 13+ 的桌面版 UA:和真 macOS 一模一样,只有触摸点数能区分。 */
const IPAD_DESKTOP_MODE_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
const WINDOWS_EDGE_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91'
const LINUX_FIREFOX_UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0'
const WECHAT_ANDROID_UA =
'Mozilla/5.0 (Linux; Android 13; 2211133C Build/TKQ1.221013.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/112.0.5615.136 Mobile Safari/537.36 MicroMessenger/8.0.49.2600(0x2800313D) WeChat/arm64 Weixin NetType/WIFI Language/zh_CN ABI/arm64'
const WECHAT_IOS_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.40(0x18002832) NetType/WIFI Language/zh_CN'
/** 低熵 UA-CH:Chromium 在**非安全上下文**下也只有这三个字段(platform 没有版本号)。 */
function uaDataLow(platform: string, mobile: boolean): UserAgentDataLike {
return { platform, mobile, brands: [{ brand: 'Chromium', version: '120' }] }
}
test('安卓手机(Chrome):UA 里的 Android 10 是归约后的假版本,高熵值才给出 13', async () => {
const source: DeviceLabelSource = {
userAgent: ANDROID_CHROME_UA,
userAgentData: {
...uaDataLow('Android', true),
getHighEntropyValues: async (): Promise<HighEntropyValues> => ({
platform: 'Android',
platformVersion: '13.0.0',
uaFullVersion: '120.0.6099.230',
model: 'Pixel 7',
}),
},
screen: { width: 1080, height: 2400 },
devicePixelRatio: 3,
}
assert.equal(deviceLabel(source).device, '安卓 10 · Chrome 120 · 1080×2400 @3x · 手机')
assert.equal((await resolveDeviceLabel(source)).device, '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机')
})
test('userAgentData 存在但 getHighEntropyValues 被拒(内网 http):退回 UA 解析,不抛异常', async () => {
const source: DeviceLabelSource = {
userAgent: ANDROID_CHROME_UA,
userAgentData: {
...uaDataLow('Android', true),
getHighEntropyValues: async () => {
throw new DOMException('Not allowed', 'SecurityError')
},
},
screen: { width: 1080, height: 2400 },
devicePixelRatio: 3,
}
assert.equal((await resolveDeviceLabel(source)).device, '安卓 10 · Chrome 120 · 1080×2400 @3x · 手机')
})
test('getHighEntropyValues 不存在(Safari / Firefox)时直接跳过,不调用也不报错', async () => {
const source: DeviceLabelSource = {
userAgent: LINUX_FIREFOX_UA,
userAgentData: uaDataLow('Linux', false),
screen: { width: 1920, height: 1080 },
devicePixelRatio: 1,
}
assert.equal((await resolveDeviceLabel(source)).device, 'Linux · Firefox 121 · 1920×1080 · 桌面')
})
test('iPad Safari:真 iPad 报 iPadOS 17 + 平板', () => {
const label = deviceLabel({
userAgent: IPAD_SAFARI_UA,
screen: { width: 2048, height: 2732 },
devicePixelRatio: 2,
maxTouchPoints: 5,
})
assert.equal(label.device, 'iPadOS 17 · Safari 17 · 2048×2732 @2x · 平板')
})
test('iPadOS 13+ 伪装成 macOS:靠 maxTouchPoints 识破,判成 iPadOS / 平板', () => {
const ipad = deviceLabel({
userAgent: IPAD_DESKTOP_MODE_UA,
screen: { width: 2048, height: 2732 },
devicePixelRatio: 2,
maxTouchPoints: 5,
})
// 伪装版拿不到系统版本(UA 里的 Version/ 是 Safari 的版本),只说系统名,不编版本号。
assert.equal(ipad.device, 'iPadOS · Safari 17 · 2048×2732 @2x · 平板')
// 同一串 UA、触摸点数为 0:这才是真 macOS 桌面,绝不能被判成 iPad。
const mac = deviceLabel({ userAgent: IPAD_DESKTOP_MODE_UA, maxTouchPoints: 0 })
assert.equal(mac.device, 'macOS · Safari 17 · 桌面')
})
test('Windows 桌面 Edge:Edge 抢在 Chrome 前面(Edge 的 UA 里也写着 Chrome)', () => {
const label = deviceLabel({
userAgent: WINDOWS_EDGE_UA,
userAgentData: uaDataLow('Windows', false),
screen: { width: 1920, height: 1080 },
devicePixelRatio: 1,
})
assert.equal(label.device, 'Windows 10 · Edge 120 · 1920×1080 · 桌面')
})
test('Linux 桌面 Firefox:没有 UA-CH 也认得出来', () => {
assert.deepEqual(parseUserAgent(LINUX_FIREFOX_UA), {
system: 'Linux',
kind: '桌面',
browser: 'Firefox 121',
})
})
test('微信内置浏览器:微信版本在前,系统与形态照常给出', () => {
assert.equal(
deviceLabel({
userAgent: WECHAT_ANDROID_UA,
screen: { width: 1080, height: 2340 },
devicePixelRatio: 2.75,
}).device,
'安卓 13 · 微信 8 · 1080×2340 @2.75x · 手机',
)
assert.equal(deviceLabel({ userAgent: WECHAT_IOS_UA }).device, 'iOS 16 · 微信 8 · 手机')
})
test('UA 与 UA-CH 都没有时给空标签,不编造内容', () => {
const label = deviceLabel({})
assert.equal(label.device, '')
assert.equal(label.deviceRaw, '')
assert.deepEqual(parseUserAgent(''), { system: '', kind: '', browser: '' })
})
test('各段缺失时整段跳过:不出现空段、双分隔符或 undefined', () => {
// 只有 UA:没有分辨率那一段。
const noScreen = deviceLabel({ userAgent: LINUX_FIREFOX_UA })
assert.equal(noScreen.device, 'Linux · Firefox 121 · 桌面')
assert.doesNotMatch(noScreen.device, / · · |undefined|null/)
// 只有 UA-CH 的 mobile:系统与浏览器都没有,只剩形态。
assert.equal(deviceLabel({ userAgentData: { mobile: true } }).device, '手机')
// 只有分辨率:小数点后两位以内,且不带倍率前缀。
const fields = composeDeviceLabel({ system: '', browser: '', screen: '1920×1080', kind: '' })
assert.equal(fields.device, '1920×1080')
assert.equal(composeDeviceLabel({ system: '', browser: '', screen: '', kind: '' }).device, '')
// screen 存在但值不合理(0 / NaN)时也不留空段。
assert.equal(deviceLabel({ screen: { width: 0, height: 1080 } }).device, '')
assert.equal(deviceLabel({ screen: { width: Number.NaN, height: 1080 } }).device, '')
})
test('形态识别:Android 平板没有 Mobile 字样,UA-CH 的 mobile 兜底其余情况', () => {
const tabletUa =
'Mozilla/5.0 (Linux; Android 12; SM-X200 Build/SP1A.210812.016) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
assert.equal(parseUserAgent(tabletUa).kind, '平板')
const tabletExplicit =
'Mozilla/5.0 (Linux; Android 12; SM-X200 Tablet) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36'
assert.equal(parseUserAgent(tabletExplicit).kind, '平板')
// Android 手机:UA 里有 Mobile,UA-CH 也说它是移动设备。
assert.equal(deviceFields({ userAgent: ANDROID_CHROME_UA, userAgentData: { mobile: true } }).kind, '手机')
// UA-CH 与 UA 冲突时以 UA-CH 为准(UA 可能被改写过):mobile=false → 平板。
assert.equal(deviceFields({ userAgent: tabletExplicit, userAgentData: { mobile: false } }).kind, '平板')
// UA 里明写着 Tablet 时仍然是平板(那是设备自己说的,比 mobile 标志更具体)。
assert.equal(deviceFields({ userAgent: tabletExplicit, userAgentData: { mobile: true } }).kind, '平板')
// UA 完全没有形态线索时才靠 mobile;UA 与 UA-CH 都没线索时不猜。
assert.equal(deviceFields({ userAgentData: { mobile: true } }).kind, '手机')
assert.equal(deviceFields({ userAgentData: { mobile: false } }).kind, '桌面')
assert.equal(deviceFields({ userAgentData: {} }).kind, '')
assert.equal(deviceFields({ userAgent: WINDOWS_EDGE_UA }).kind, '桌面')
})
test('HarmonyOS 不会被说成安卓,UC 与旧式 Edge 也认得出来', () => {
const harmonyUa =
'Mozilla/5.0 (Linux; Android 10; HarmonyOS; ALN-AL00; HMSCore 6.13.0.302) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
assert.equal(parseUserAgent(harmonyUa).system, 'HarmonyOS')
// 华为的浏览器有 UA-CH,而它的 platform 只会说 Android(比 UA 更粗):不能被顶替成「安卓 13」。
assert.equal(
deviceLabel({ userAgent: harmonyUa, userAgentData: { platform: 'Android', platformVersion: '13.0.0' } })
.device,
'HarmonyOS · Chrome 114 · 手机',
)
const ucUa =
'Mozilla/5.0 (Linux; U; Android 11; zh-CN; M2012K11AC Build/RKQ1.200826.002) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/78.0.3904.108 UCBrowser/13.4.2.1307 Mobile Safari/537.36'
assert.equal(parseUserAgent(ucUa).browser, 'UC 13')
const oldEdgeUa =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edge/18.19045'
assert.equal(parseUserAgent(oldEdgeUa).browser, 'Edge 18')
})
test('标签超过 80 字符会截断,且不会切出半个多字节字符', () => {
// 认不出的平台名会原样进标签,拿它把标签撑到超过上限(含代理对,专门测「半个字符」)。
const longPlatform = '😀'.repeat(100)
const label = deviceLabel({ userAgentData: { platform: longPlatform } })
assert.equal(Array.from(label.device).length, DEVICE_LABEL_MAX)
assert.ok(label.device.endsWith('…'), '截断处要有省略号,让人看出这名字被截过')
assert.doesNotMatch(label.device, /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/)
// 刚好到上限时不截断。
const exact = '中'.repeat(DEVICE_LABEL_MAX)
assert.equal(truncateDeviceLabel(exact), exact)
assert.equal(truncateDeviceLabel(`${exact}中`), `${'中'.repeat(DEVICE_LABEL_MAX - 1)}…`)
})
test('deviceRaw 原样带上原始 UA,给悬浮提示排障用', () => {
const label = deviceLabel({ userAgent: WINDOWS_EDGE_UA, screen: { width: 1920, height: 1080 } })
assert.equal(label.deviceRaw, WINDOWS_EDGE_UA)
})
@@ -0,0 +1,70 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
/**
* 接线钉子:设备标签与改名的「最后一公里」。
*
* 大屏(`.vue` / `useScoreboard.ts`)在仓库里跑不进测试(没有 jsdom / @vue/test-utils),
* 所以纯逻辑测完还必须钉住「真的接上了」—— 少了这一步,最典型的半成品长这样:
* 解析函数写得漂漂亮亮、管理端也渲染了 alias,但 hello 里压根没带 device,
* 或者列表还在渲染实例 id 而没接上 instanceDisplayName()。两者都编译得过、单测也全绿。
*/
const USE_SCOREBOARD = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8')
const DISPLAY_PAGE = readFileSync(new URL('../../admin/pages/DisplayPage.vue', import.meta.url), 'utf8')
test('useScoreboard 的 hello 带上 device / deviceRaw', () => {
assert.match(USE_SCOREBOARD, /device: label\.device/, 'hello 必须带上设备标签')
assert.match(USE_SCOREBOARD, /deviceRaw: label\.deviceRaw/, 'hello 必须带上原始 UA')
assert.match(USE_SCOREBOARD, /const label = deviceLabel\(deviceLabelSource\(\)\)/, '标签要走设备解析层')
})
test('useScoreboard 用本机稳定实例 id,并在高熵值到达后补齐标签', () => {
// 实例 id 稳定化:不稳定的 id 会让 setPage 补发的那条 hello 在服务端留下离线残影。
assert.match(USE_SCOREBOARD, /resolveDisplayIdentity\(/)
assert.match(USE_SCOREBOARD, /instanceIdStorage\(\)/, '必须从 localStorage 取本机稳定 id')
assert.doesNotMatch(
USE_SCOREBOARD,
/instanceId: options\.value\.instance \?\? undefined/,
'不能再把「URL 没带 instance」直接当成没有实例 id',
)
assert.match(USE_SCOREBOARD, /client\?\.setDevice\(refined\.device, refined\.deviceRaw\)/)
assert.match(USE_SCOREBOARD, /void refineDeviceLabel\(\)/)
})
test('管理端列表渲染别名 / 设备标签,并把 id 降级成小字', () => {
assert.match(DISPLAY_PAGE, /\{\{ instanceDisplayName\(instance\) \}\}/, '主行名称要走名称优先级')
assert.match(DISPLAY_PAGE, /v-if="instanceIdNote\(instance\) !== null"/, 'id 降级成小字,仅在有别名/标签时显示')
assert.match(DISPLAY_PAGE, /:title="instanceTooltip\(instance\)"/, '悬浮提示要给排障信息')
assert.doesNotMatch(
DISPLAY_PAGE,
/<span class="instance-id">\{\{ instance\.id \}\}<\/span>/,
'主行不能再直接渲染实例 id',
)
})
test('管理端有改名入口:可写才可点、保存调接口、失败给中文报错', () => {
assert.match(DISPLAY_PAGE, /@click="startRename\(instance\)"/, '每行要有改名按钮')
assert.match(DISPLAY_PAGE, /:disabled="!canWrite \|\| renamePending !== null"/, '只读访客不能改名')
assert.match(DISPLAY_PAGE, /renameGateReason \?\? ALIAS_RENAME_HINT/, '禁用时要写清原因')
assert.match(DISPLAY_PAGE, /adminApi\.displayAlias\(instance\.id, alias\)/, '保存要走别名接口')
assert.match(DISPLAY_PAGE, /aliasPayload\(renameDraft\.value\)\.alias/, '提交前统一 trim')
assert.match(DISPLAY_PAGE, /@click="clearAlias\(instance\)"/, '要有「清除」入口回到自动标签')
// 保存成功后必须刷新列表(服务端记录才是权威),失败要把原文交给 toast。
assert.match(DISPLAY_PAGE, /await adminApi\.displayAlias[\s\S]{0,200}?await refreshInstances\(\)/)
assert.match(DISPLAY_PAGE, /toast\.error\('改名失败'/)
})
test('原有的徽标 / 当前页 / 来源 / 心跳 / 切页控件一个都没丢', () => {
for (const anchor of [
/\{\{ instance\.online \? '在线' : '离线' \}\}/,
/v-if="instance\.slow" class="mx-badge mx-badge-warning">落后</,
/instance\.screen === '' \? '局域网浏览器' : `屏 \$\{instance\.screen\}`/,
/当前页:\{\{ instance\.page === '' \? '未上报' : pageLabel\(instance\.page\) \}\}/,
/来源:\{\{ instance\.remoteAddr === '' \? '未知' : instance\.remoteAddr \}\}/,
/最后心跳:\{\{ formatDateTime\(instance\.lastHeartbeat\) \}\}/,
/@click="gotoInstance\(instance\)"/,
]) {
assert.match(DISPLAY_PAGE, anchor)
}
})
@@ -0,0 +1,152 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { RealtimeClient } from '@/shared'
import { deviceLabel, type DeviceLabelSource } from '../deviceLabel'
import { DISPLAY_INSTANCE_STORAGE_KEY, resolveDisplayIdentity, type InstanceIdStorage } from '../instanceId'
/**
* 一条端到端的接线:局域网浏览器从「解析实例 id」到「真的发出 hello」。
*
* 修的就是这条链路:`setPage()` 翻页后会**再发一次 hello**(协议只有 hello 这一种上报方式),
* 而局域网浏览器原先 displayKey / instanceId 都是空的 —— 服务端 `registerDisplay` 在 id 为空时
* **每次现生成一个** `display-<随机>`,于是切一次页就在注册表里多留一条该设备的离线残影
* (管理端列表里凭空多出一条离线条目,TTL 十分钟)。
*
* 这里用真的 RealtimeClient + 真的 deviceLabel + 真的 instanceId 解析跑一遍:两条 hello 的
* instanceId 必须一模一样。纯逻辑测试测不出这件事(它们各自都对,接起来才出问题)。
*/
class FakeSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSED = 3
static instances: FakeSocket[] = []
readyState = FakeSocket.CONNECTING
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
readonly sent: string[] = []
constructor() {
FakeSocket.instances.push(this)
}
send(data: string): void {
this.sent.push(data)
}
close(): void {
this.readyState = FakeSocket.CLOSED
this.onclose?.({ code: 1006 })
}
open(): void {
this.readyState = FakeSocket.OPEN
this.onopen?.()
}
hellos(): Array<Record<string, unknown>> {
return this.sent
.map((raw) => JSON.parse(raw) as { event: string; data: Record<string, unknown> })
.filter((message) => message.event === 'hello')
.map((message) => message.data)
}
}
function fakeStorage(initial: Record<string, string> = {}): InstanceIdStorage {
const map = new Map(Object.entries(initial))
return {
getItem: (key) => map.get(key) ?? null,
setItem: (key, value) => void map.set(key, value),
}
}
/** 安卓手机打开局域网地址:没有 instance / screen,只有 UA 与屏幕信息。 */
const ANDROID_SOURCE: DeviceLabelSource = {
userAgent:
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
screen: { width: 1080, height: 2400 },
devicePixelRatio: 3,
}
function connectLanBrowser(
storage: InstanceIdStorage,
source: DeviceLabelSource,
): { socket: FakeSocket; client: RealtimeClient } {
;(globalThis as { WebSocket?: unknown }).WebSocket = FakeSocket
FakeSocket.instances = []
const identity = resolveDisplayIdentity({ instance: null, screen: null }, storage)
const label = deviceLabel(source)
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
displayKey: identity.displayKey,
instanceId: identity.instanceId,
device: label.device,
deviceRaw: label.deviceRaw,
page: 'standings',
})
client.connect()
const socket = FakeSocket.instances[0]
assert.ok(socket)
socket.open()
return { socket, client }
}
test('局域网浏览器:切页补发的两条 hello 用同一个实例 id(不再多出离线残影)', () => {
const { socket, client } = connectLanBrowser(fakeStorage(), ANDROID_SOURCE)
client.setPage('team-medals')
const hellos = socket.hellos()
assert.equal(hellos.length, 2, '切页后必须补发一条 hello(服务端注册表里的当前页要跟上)')
assert.equal(typeof hellos[0]?.instanceId, 'string')
assert.match(String(hellos[0]?.instanceId), /^browser-[0-9a-f]{8}$/)
assert.equal(hellos[1]?.instanceId, hellos[0]?.instanceId, '两条 hello 的实例 id 必须一致')
assert.equal(hellos[1]?.displayKey, hellos[0]?.displayKey)
assert.equal(hellos[0]?.device, '安卓 10 · Chrome 120 · 1080×2400 @3x · 手机')
assert.equal(hellos[1]?.page, 'team-medals')
client.close()
})
test('刷新 / 重连(同一个浏览器)沿用 localStorage 里那一个实例 id', () => {
const storage = fakeStorage()
const first = connectLanBrowser(storage, ANDROID_SOURCE)
const firstName = first.socket.hellos()[0]?.instanceId
first.client.close()
// 重新打开页面:URL 还是没参数,但存储里有上一次那个 id。
const second = connectLanBrowser(storage, ANDROID_SOURCE)
assert.equal(second.socket.hellos()[0]?.instanceId, firstName)
second.client.close()
// 重新连接也仍然读同一个键。
assert.equal(typeof storage.getItem(DISPLAY_INSTANCE_STORAGE_KEY), 'string')
})
test('隐私模式(存储不可用):不报实例 id,行为退回今天的样子', () => {
const brokenStorage: InstanceIdStorage = {
getItem: () => {
throw new Error('SecurityError')
},
setItem: () => {
throw new Error('SecurityError')
},
}
const { socket, client } = connectLanBrowser(brokenStorage, ANDROID_SOURCE)
const hello = socket.hellos()[0]
assert.equal(hello?.instanceId, undefined, '存不下就不报,交给服务端生成')
assert.equal(hello?.displayKey, undefined)
// 标签照常有:存储问题不该影响设备信息。
assert.equal(hello?.device, '安卓 10 · Chrome 120 · 1080×2400 @3x · 手机')
client.close()
})
test('Console 打开的窗口:实例 id 与屏号仍然来自 URL,一个字不变', () => {
const identity = resolveDisplayIdentity({ instance: 'LAPTOP-1-2528732444', screen: '2' }, fakeStorage())
assert.equal(identity.displayKey, 'LAPTOP-1-2528732444')
assert.equal(identity.instanceId, 'LAPTOP-1-2528732444')
})
+134
View File
@@ -0,0 +1,134 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
DISPLAY_INSTANCE_ID_PREFIX,
DISPLAY_INSTANCE_STORAGE_KEY,
randomBrowserId,
resolveDisplayIdentity,
resolveInstanceId,
type InstanceIdStorage,
} from '../instanceId'
/**
* 局域网浏览器的实例 id 稳定化。
*
* 修的 bug:`RealtimeClient.setPage()` 翻页后会**再发一次 hello**,而局域网浏览器原先
* displayKey / instanceId 都是空,服务端每收到一次 hello 就现生成一个 `display-<随机>` ——
* 老连接那条留在注册表里变成「离线」条目(管理端列表凭空多出一条该设备的离线记录)。
* id 稳定下来之后,重发 hello 只是刷新同一条记录。
*/
/** 假的 localStorage:getItem / setItem 可以按需抛异常(隐私模式的两种表现)。 */
function fakeStorage(
initial: Record<string, string> = {},
options: { throwOnGet?: boolean; throwOnSet?: boolean } = {},
): InstanceIdStorage & { dump(): Record<string, string> } {
const map = new Map(Object.entries(initial))
return {
getItem(key: string): string | null {
if (options.throwOnGet) throw new Error('SecurityError: 存储被禁用')
return map.get(key) ?? null
},
setItem(key: string, value: string): void {
if (options.throwOnSet) throw new Error('QuotaExceededError')
map.set(key, value)
},
dump: () => Object.fromEntries(map),
}
}
function fixedRandom(value: string): () => string {
return () => value
}
test('URL 里带了实例 id / 屏号时原样用,且不碰存储', () => {
const consoleStorage = fakeStorage()
assert.equal(resolveInstanceId({ instance: 'LAPTOP-1-2528732444' }, consoleStorage), 'LAPTOP-1-2528732444')
// 只带屏号的窗口(Console 是这么开的)沿用 URL 的值,行为与升级前一致。
assert.equal(resolveInstanceId({ instance: null, screen: '2' }, consoleStorage), '2')
assert.deepEqual(consoleStorage.dump(), {}, 'Console 窗口不该往 localStorage 里写东西')
})
test('局域网浏览器(URL 没有 instance / screen):生成并写回本机稳定 id', () => {
const storage = fakeStorage()
const first = resolveInstanceId({}, storage, fixedRandom('3f2a1c9d'))
assert.equal(first, `${DISPLAY_INSTANCE_ID_PREFIX}3f2a1c9d`)
assert.equal(storage.dump()[DISPLAY_INSTANCE_STORAGE_KEY], first)
// 第二次调用(刷新、重连、切页补发 hello)必须是同一个:这里换一个随机源,证明它没有重新生成。
assert.equal(resolveInstanceId({}, storage, fixedRandom('ffffffff')), first)
})
test('存过的 id 直接用,连随机源都不用调', () => {
const storage = fakeStorage({ [DISPLAY_INSTANCE_STORAGE_KEY]: 'browser-abcdef01' })
const broken = (): string => {
throw new Error('不该在这里生成新 id')
}
assert.equal(resolveInstanceId({}, storage, broken), 'browser-abcdef01')
})
test('存储抛异常时返回 undefined,绝不让大屏连不上', () => {
// 读属性就抛的隐私模式在页面里的表现是 storage === null(见 instanceIdStorage)。
assert.equal(resolveInstanceId({}, null), undefined)
assert.equal(resolveInstanceId({}, fakeStorage({}, { throwOnGet: true })), undefined)
assert.equal(resolveInstanceId({}, fakeStorage({}, { throwOnSet: true })), undefined)
})
test('存下来的脏值不认:重新生成一个,而不是拿它去当实例 id', () => {
for (const bad of ['', ' ', 'x'.repeat(65), '{"id":"1"}', 'browser-😀']) {
const storage = fakeStorage({ [DISPLAY_INSTANCE_STORAGE_KEY]: bad })
assert.equal(
resolveInstanceId({}, storage, fixedRandom('00112233')),
`${DISPLAY_INSTANCE_ID_PREFIX}00112233`,
`脏值 ${JSON.stringify(bad)} 应被丢弃并重生成`,
)
}
})
test('空的 URL 参数(?instance=)按「没给」处理,落到本机稳定 id', () => {
const storage = fakeStorage()
// 以前 `?instance=` 会让 displayKey / instanceId 都变成空串,服务端照样现生成一个 —— 正是本文件要修的 bug。
assert.equal(resolveInstanceId({ instance: '', screen: '' }, storage, fixedRandom('aabbccdd')), 'browser-aabbccdd')
})
test('resolveDisplayIdentity:Console 窗口行为一个字不变,局域网浏览器两个字段都用稳定 id', () => {
const storage = fakeStorage()
// Console 窗口:displayKey 与 instanceId 都来自 URL 的 instance。
assert.deepEqual(resolveDisplayIdentity({ instance: 'LAPTOP-1-9' }, storage, fixedRandom('x')), {
displayKey: 'LAPTOP-1-9',
instanceId: 'LAPTOP-1-9',
browserId: undefined,
})
// 只带屏号:instanceId 仍然不上报(服务端按 displayKey 认它),与升级前完全一致。
assert.deepEqual(resolveDisplayIdentity({ screen: '2' }, storage, fixedRandom('x')), {
displayKey: '2',
instanceId: undefined,
browserId: undefined,
})
// 局域网浏览器:displayKey 与 instanceId 都是本机稳定 id,重发 hello 只会刷新同一条记录。
const lan = resolveDisplayIdentity({}, storage, fixedRandom('a1b2c3d4'))
assert.deepEqual(lan, {
displayKey: 'browser-a1b2c3d4',
instanceId: 'browser-a1b2c3d4',
browserId: 'browser-a1b2c3d4',
})
assert.deepEqual(resolveDisplayIdentity({}, storage, fixedRandom('ffffffff')), lan)
// 存储不可用:退回今天的行为(都为空,交给服务端生成),不能因此连不上。
assert.deepEqual(resolveDisplayIdentity({}, null), {
displayKey: undefined,
instanceId: undefined,
browserId: undefined,
})
})
test('浏览器生成器:格式是 browser-<8 位十六进制>', () => {
assert.match(randomBrowserId(), /^[0-9a-f]{8}$/)
assert.match(`${DISPLAY_INSTANCE_ID_PREFIX}${randomBrowserId()}`, /^browser-[0-9a-f]{8}$/)
})
+29 -2
View File
@@ -25,6 +25,8 @@ import { entryShareIndex, entrySquadIndex } from './jointTeams'
import type { DisplayTeamShare } from './jointTeams' import type { DisplayTeamShare } from './jointTeams'
import { entryRosterIndex } from './members' import { entryRosterIndex } from './members'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce' import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { deviceLabel, deviceLabelSource, resolveDeviceLabel } from './deviceLabel'
import { instanceIdStorage, resolveDisplayIdentity } from './instanceId'
import { applyDisplayFonts } from './fonts' import { applyDisplayFonts } from './fonts'
import { liveRefreshDelay } from './live' import { liveRefreshDelay } from './live'
import { createMockAthletes, createMockSnapshot } from './mock' import { createMockAthletes, createMockSnapshot } from './mock'
@@ -686,13 +688,24 @@ watch(
function connect(): void { function connect(): void {
if (client !== null) return if (client !== null) return
// 实例 id 必须在本机稳定:局域网浏览器(URL 没有 instance / screen)每次现生成一个的话,
// setPage 补发的那条 hello 会在服务端注册表里留下一条永远离线的老实例(TTL 十分钟),
// 管理端列表里就凭空多出「同一台设备的离线条目」——取舍与键名见 instanceId.ts。
const identity = resolveDisplayIdentity(
{ instance: options.value.instance, screen: options.value.screen },
instanceIdStorage(),
)
const label = deviceLabel(deviceLabelSource())
client = new RealtimeClient({ client = new RealtimeClient({
role: 'display', role: 'display',
displayKey: options.value.instance ?? options.value.screen ?? undefined, displayKey: identity.displayKey,
instanceId: options.value.instance ?? undefined, instanceId: identity.instanceId,
// Console 从 URL 带下来的屏号(screen=<显示器 id>),让管理端把本机显示器与在线实例对上; // Console 从 URL 带下来的屏号(screen=<显示器 id>),让管理端把本机显示器与在线实例对上;
// 局域网浏览器直接开 /display 时没有它,留空。 // 局域网浏览器直接开 /display 时没有它,留空。
screen: options.value.screen ?? undefined, screen: options.value.screen ?? undefined,
// 设备标签:管理端列表拿它当主行(alias 优先),原始 UA 留给悬浮提示排障。
device: label.device,
deviceRaw: label.deviceRaw,
page: page.value, page: page.value,
initialVersion: version.value, initialVersion: version.value,
onSnapshot: (message) => { onSnapshot: (message) => {
@@ -742,6 +755,9 @@ async function bootstrap(): Promise<void> {
hydrateFromCache() hydrateFromCache()
connect() connect()
// 设备标签的高熵值(真实系统版本)只能异步拿,拿到了就补一条 hello;拿不到(内网 http 会被
// 直接拒绝)什么都不做,列表主行用同步那份标签。
void refineDeviceLabel()
const restOk = await loadRest() const restOk = await loadRest()
if (!restOk && version.value === null) activateMock() if (!restOk && version.value === null) activateMock()
@@ -749,6 +765,17 @@ async function bootstrap(): Promise<void> {
if (!locked.value) startPageRotation() if (!locked.value) startPageRotation()
} }
/**
* 高熵值补齐设备标签(`getHighEntropyValues`)。
*
* 只在这一件事上有用:Chrome 把 Android 的 UA 归约成固定的 `Android 10`,真实系统版本只在
* 高熵的 `platformVersion` 里。拿不到就保持同步那份标签,`setDevice` 自己会判断有没有变化。
*/
async function refineDeviceLabel(): Promise<void> {
const refined = await resolveDeviceLabel(deviceLabelSource())
client?.setDevice(refined.device, refined.deviceRaw)
}
function dispose(): void { function dispose(): void {
stopPageRotation() stopPageRotation()
clearDetailTimer() clearDetailTimer()
+45 -1
View File
@@ -435,12 +435,28 @@ export interface DisplayInstance {
slow: boolean slow: boolean
/** 屏号;局域网浏览器直接开 /display 时是空串。 */ /** 屏号;局域网浏览器直接开 /display 时是空串。 */
screen: string screen: string
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`)。
*
* 老客户端 / 老服务端没有这个字段(omitempty),此时为 undefined——列表退回显示 id。
*/
device?: string
/** 原始 UA,悬浮提示排障用;同样可能缺字段。 */
deviceRaw?: string
/** 操作员手动设的名字(机器级,跟着实例 id 走);没设过时为 undefined。 */
alias?: string
} }
export interface DisplayApi { export interface DisplayApi {
command(command: DisplayCommand): Promise<void> command(command: DisplayCommand): Promise<void>
/** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */ /** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */
instances(): Promise<DisplayInstance[]> instances(): Promise<DisplayInstance[]>
/**
* 给某个实例设置 / 清除别名(`PUT /api/display/instances/{id}/alias`,录入员及以上)。
*
* 空串(或只空白)表示清除,实例回到自动标签;返回该实例的最新记录(形状与列表元素一致)。
*/
setAlias(id: string, alias: string): Promise<DisplayInstance>
/** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */ /** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */
live(): Promise<LiveEvents> live(): Promise<LiveEvents>
} }
@@ -722,7 +738,7 @@ function readDisplayInstance(value: unknown): DisplayInstance | null {
const id = readString(value, 'id') const id = readString(value, 'id')
if (id === null || id === '') return null if (id === null || id === '') return null
const ackVersion = value['ackVersion'] const ackVersion = value['ackVersion']
return { const instance: DisplayInstance = {
id, id,
online: value['online'] === true, online: value['online'] === true,
page: readString(value, 'page') ?? '', page: readString(value, 'page') ?? '',
@@ -733,6 +749,23 @@ function readDisplayInstance(value: unknown): DisplayInstance | null {
slow: value['slow'] === true, slow: value['slow'] === true,
screen: readString(value, 'screen') ?? '', screen: readString(value, 'screen') ?? '',
} }
// 这三个是后加的字段(老服务端不带):**缺字段时整个键不出现**,而不是补空串——
// 老服务端升级前那些用例是按「对象形状完全一致」钉住的,多一个空串键就会把它们判成回归。
// 只空白同理按「没有」处理:服务端把只空白当清除,界面这边也必须同口径。
const device = trimmed(readString(value, 'device'))
if (device !== null) instance.device = device
const deviceRaw = trimmed(readString(value, 'deviceRaw'))
if (deviceRaw !== null) instance.deviceRaw = deviceRaw
const alias = trimmed(readString(value, 'alias'))
if (alias !== null) instance.alias = alias
return instance
}
/** 空串 / 只空白都当成「没有这个值」;有内容时去掉首尾空白(粘贴来的名字几乎都带着)。 */
function trimmed(value: string | null): string | null {
if (value === null) return null
const text = value.trim()
return text === '' ? null : text
} }
function readProjectInfo(value: unknown): ProjectInfo | null { function readProjectInfo(value: unknown): ProjectInfo | null {
@@ -1010,6 +1043,17 @@ export class ApiClient {
} }
return body.map(readDisplayInstance).filter((item): item is DisplayInstance => item !== null) return body.map(readDisplayInstance).filter((item): item is DisplayInstance => item !== null)
}, },
setAlias: async (id, alias) => {
const body = await http.request<unknown>(
`/display/instances/${encodeURIComponent(id)}/alias`,
{ method: 'PUT', body: { alias } },
)
const instance = readDisplayInstance(body)
if (instance === null) {
throw new ApiError('PARSE_FAILED', '大屏实例别名响应格式不符', 0, body)
}
return instance
},
live: () => http.request<LiveEvents>('/display/live'), live: () => http.request<LiveEvents>('/display/live'),
} }
+38
View File
@@ -39,6 +39,15 @@ export interface RealtimeOptions {
instanceId?: string instanceId?: string
/** 屏号(Console 从 URL 带下来的 screen),hello 时上报;局域网浏览器没有屏号,留空。 */ /** 屏号(Console 从 URL 带下来的 screen),hello 时上报;局域网浏览器没有屏号,留空。 */
screen?: string screen?: string
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`),管理端「在线大屏」列表的主行。
*
* 空串等同于不传:管理端会退回显示实例 id,老服务端也根本认不出这个字段。
* 高熵值(`getHighEntropyValues`)是异步的,拿到更准的版本后用 setDevice 再补一次。
*/
device?: string
/** 原始 UA,给管理端悬浮提示排障用;空串等同于不传。 */
deviceRaw?: string
/** 当前页名,hello 时上报;翻页后调用 setPage 再补一次。 */ /** 当前页名,hello 时上报;翻页后调用 setPage 再补一次。 */
page?: string page?: string
/** 大屏从 localStorage 恢复快照后传入,可让服务端只补增量。 */ /** 大屏从 localStorage 恢复快照后传入,可让服务端只补增量。 */
@@ -93,6 +102,8 @@ export class RealtimeClient {
private readonly displayKey: string | undefined private readonly displayKey: string | undefined
private readonly instanceId: string | undefined private readonly instanceId: string | undefined
private readonly screen: string | undefined private readonly screen: string | undefined
private device: string | undefined
private deviceRaw: string | undefined
private readonly reconnect: Required<ReconnectOptions> private readonly reconnect: Required<ReconnectOptions>
private readonly watchdogMs: number private readonly watchdogMs: number
private readonly handshakeMs: number private readonly handshakeMs: number
@@ -128,6 +139,8 @@ export class RealtimeClient {
this.displayKey = options.displayKey this.displayKey = options.displayKey
this.instanceId = options.instanceId this.instanceId = options.instanceId
this.screen = options.screen this.screen = options.screen
this.device = emptyToUndefined(options.device)
this.deviceRaw = emptyToUndefined(options.deviceRaw)
this.currentPage = options.page this.currentPage = options.page
this.currentVersion = options.initialVersion ?? null this.currentVersion = options.initialVersion ?? null
this.watchdogMs = options.watchdogMs ?? 25_000 this.watchdogMs = options.watchdogMs ?? 25_000
@@ -225,6 +238,21 @@ export class RealtimeClient {
if (this.currentStatus === 'online') this.sendHello() if (this.currentStatus === 'online') this.sendHello()
} }
/**
* 设备标签补齐后再补一条 hello(高熵值 `getHighEntropyValues` 是异步的)。
*
* 与 setPage 同一套做法:协议只有 hello 这一种上报方式。重复补发不会再多出实例条目——
* 局域网浏览器的实例 id 已经在本机稳定下来了(见 display/instanceId.ts)。
*/
setDevice(device: string, deviceRaw: string): void {
const next = emptyToUndefined(device)
const nextRaw = emptyToUndefined(deviceRaw)
if (next === this.device && nextRaw === this.deviceRaw) return
this.device = next
this.deviceRaw = nextRaw
if (this.currentStatus === 'online') this.sendHello()
}
close(): void { close(): void {
this.closedByUser = true this.closedByUser = true
this.clearReconnectTimer() this.clearReconnectTimer()
@@ -382,6 +410,9 @@ export class RealtimeClient {
if (this.displayKey !== undefined) hello.displayKey = this.displayKey if (this.displayKey !== undefined) hello.displayKey = this.displayKey
if (this.instanceId !== undefined) hello.instanceId = this.instanceId if (this.instanceId !== undefined) hello.instanceId = this.instanceId
if (this.screen !== undefined) hello.screen = this.screen if (this.screen !== undefined) hello.screen = this.screen
// 设备标签可选:空串时不带这个字段,与老服务端(不认识它)保持同一份载荷形状。
if (this.device !== undefined) hello.device = this.device
if (this.deviceRaw !== undefined) hello.deviceRaw = this.deviceRaw
if (this.currentPage !== undefined) hello.page = this.currentPage if (this.currentPage !== undefined) hello.page = this.currentPage
this.send('hello', hello) this.send('hello', hello)
} }
@@ -474,3 +505,10 @@ export class RealtimeClient {
function toError(error: unknown): Error { function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error)) return error instanceof Error ? error : new Error(String(error))
} }
/** 空串 / 纯空白等同于「没给」:不带这个字段比带一个空串干净,老服务端也不会有多余判断。 */
function emptyToUndefined(value: string | undefined): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
+93
View File
@@ -100,6 +100,99 @@ test('hello 上报 page 与 instanceId,翻页后补发', () => {
client.close() client.close()
}) })
// 设备标签(管理端「在线大屏」列表的主行):有就带上,没有就不出现在载荷里。
test('hello 上报设备标签与原始 UA', () => {
installFakeWebSocket()
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
instanceId: 'browser-a1b2c3d4',
device: '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机',
deviceRaw: 'Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36',
})
client.connect()
const socket = firstSocket()
socket.open()
assert.deepEqual(JSON.parse(socket.sent[0] ?? '{}'), {
event: 'hello',
data: {
role: 'display',
instanceId: 'browser-a1b2c3d4',
device: '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机',
deviceRaw: 'Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36',
},
})
client.close()
})
// 老服务端不认识这两个字段,空串也不该带上去:载荷形状与升级前完全一致。
test('没有设备标签 / 传空串时 hello 不带这两个字段', () => {
installFakeWebSocket()
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
instanceId: 'browser-a1b2c3d4',
device: '',
deviceRaw: ' ',
})
client.connect()
const socket = firstSocket()
socket.open()
assert.deepEqual(JSON.parse(socket.sent[0] ?? '{}'), {
event: 'hello',
data: { role: 'display', instanceId: 'browser-a1b2c3d4' },
})
client.close()
})
// 高熵值(真实系统版本)是异步拿的,拿到之后用 setDevice 补一条 hello;没变化就不打扰服务端。
test('setDevice 补发 hello:值变化才发,清空后也不再带这两个字段', () => {
installFakeWebSocket()
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
instanceId: 'browser-a1b2c3d4',
device: '安卓 10 · Chrome 120 · 手机',
})
client.connect()
const socket = firstSocket()
socket.open()
socket.sent.length = 0
client.setDevice('安卓 10 · Chrome 120 · 手机', '')
assert.equal(socket.sent.length, 0, '没变化不该补发')
client.setDevice('安卓 13 · Chrome 120 · 1080×2400 @3x · 手机', 'Mozilla/5.0')
const refined = JSON.parse(socket.sent[0] ?? '{}').data
assert.equal(refined.device, '安卓 13 · Chrome 120 · 1080×2400 @3x · 手机')
assert.equal(refined.deviceRaw, 'Mozilla/5.0')
socket.sent.length = 0
client.setDevice('', '')
const cleared = JSON.parse(socket.sent[0] ?? '{}').data
assert.equal('device' in cleared, false)
assert.equal('deviceRaw' in cleared, false)
client.close()
})
// 离线(还没握手完 / 已断开)时 setDevice 只记下来,等这条连接自己 hello 时带上。
test('未连上时 setDevice 不发消息,等下一次 hello 带上', () => {
installFakeWebSocket()
const client = new RealtimeClient({ role: 'display', url: 'ws://test/ws' })
client.connect()
const socket = firstSocket()
socket.sent.length = 0
client.setDevice('Windows 10 · Edge 120 · 1920×1080 · 桌面', 'Mozilla/5.0')
assert.equal(socket.sent.length, 0)
socket.open()
assert.equal(JSON.parse(socket.sent[0] ?? '{}').data.device, 'Windows 10 · Edge 120 · 1920×1080 · 桌面')
client.close()
})
// P1-17:策略性拒绝(1008)重连也会被同样拒绝,必须停重连并提示登录。 // P1-17:策略性拒绝(1008)重连也会被同样拒绝,必须停重连并提示登录。
test('close 1008 停止重连并回调 onAuthRequired', async () => { test('close 1008 停止重连并回调 onAuthRequired', async () => {
installFakeWebSocket() installFakeWebSocket()
+9
View File
@@ -1093,6 +1093,15 @@ export interface HelloMessage {
* 管理端用它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时留空。 * 管理端用它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时留空。
*/ */
screen?: string screen?: string
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`),管理端「在线大屏」列表的主行。
*
* 浏览器自己算好报上来:服务端不解析 UA,只做长度截断与清洗;老客户端不带这个字段时
* 服务端行为与今天完全一致(列表退回显示 id)。
*/
device?: string
/** 原始 UA,管理端悬浮提示里给排障用;可为空。 */
deviceRaw?: string
} }
export interface AckMessage { export interface AckMessage {