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",
"productName": "Scoreboard Console",
"version": "0.5.14",
"version": "0.5.15",
"private": true,
"author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+4 -3
View File
@@ -63,11 +63,12 @@
- [ ] 只有一块屏时是窗口化模式,按 **F11** 可切全屏(用于单屏测试)
- [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」;窗口化窗口(单屏)里显示这一行属正常,按 F11 全屏后它与自绘标题栏一起消失(版本号角标已移除,任何画面都不该出现)
- [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常)
- [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常)
- [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」,并带一个**可读的设备标签**;两台同型号设备分不清就点「**改名**」起一个现场看得懂的名字),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常)
- [ ] **局域网大屏能在别的设备打开**:用另一台设备(平板 / 手机)浏览器打开 `http://<主机IP>:19540/display` 能出画面,或直接扫「大屏控制 → 局域网大屏」卡片第一个地址的**二维码**;打不开时先看卡片列出的地址与提示,再按常见问题「大屏控制页显示未检测到局域网地址怎么办?」逐条排查(若卡片地址是 `169.254.x.x`,说明本网段没有 DHCP,同网段的设备照样能连)
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
- [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏)
- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布 → 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布)
- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布(发布窗口里按当天口径选「只更新名次」或「按决赛成绩发布」,选完才确认)→ 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布)
- [ ] **分项成绩页看得全**:切到大屏「分项成绩」页,**每一栏(含第 3 栏)都从第 1 名开始**;**团体项目的名次列不应是 `-`**(升级后不用重发、不用重算,刷新即可)。⚠️ 例外:只在「团体名次」卡片里按单位录了名次、成绩行上没填的项目仍无大屏名次(见常见问题 Q105 / Q106)
- [ ] **报名项批量操作演练**:在「成绩录入」勾选几行试一次**批量退赛 → 批量恢复**(确认框会逐条列出被跳过的 dq / dns 行及原因);「批量移出报名」是**物理删除、不可恢复**,只演示确认框、不要在真实报名上执行(详见使用手册 4.8)
- [ ] **报名名单导出演练**:「导出 → 结果导出 → 报名名单」按小项 / 组别 / 单位 / 批次筛一次并导出,核对 13 列与状态中文(正常参赛 / 已退赛 / 取消资格 / 未出场)与录入页一致
@@ -82,7 +83,7 @@
## 7. 现场流程演练(建议至少一次)
- [ ] 录入员登录 → 录入一条成绩 → 存草稿 → 发布 → 大屏刷新
- [ ] 录入员登录 → 录入一条成绩 → 存草稿 → 点「**发布**」(弹出的窗口里**默认是「只更新名次(不计分、不发奖牌)」**;今天这一项要计分发牌就选「**按决赛成绩发布**」再确认)→ 大屏刷新
- [ ] 撤销发布 → 修改 → 重新发布(历史记录里能看到链路)
- [ ] 导出核对:「结果导出」里「全部」与「全部(单页)」都能正常下载(后者应是一个工作簿、只有一张表);「积分榜」页三个榜单的卡片头各有导出——单位总分榜「导出总明细」得到 `单位积分总明细.xlsx`、单位奖牌榜「导出奖牌明细」得到 `单位奖牌明细.xlsx`、个人积分榜「导出个人明细」得到 `个人积分总明细.xlsx`;展开某单位后还能点「导出该单位明细」得到 `单位积分明细.xlsx`
- [ ] 断网 → 大屏仍显示缓存数据 → 恢复后自动补增量
+61 -6
View File
@@ -112,6 +112,8 @@
表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。**纯团体报名这一格旁边还有一个「队员」小按钮**(点开登记 / 修改这支队的队员,见 4.13);普通报名没有这个按钮。
**「运动员 / 队」这一列会限宽,长名单可以手动展开**:多人团体项目的队员名单(`张伟(A001)、李强(A002)…`)以前会把整列撑得很宽,现在**收起时限宽**(**14em ≈ 182px**,超出显示省略号);名单够长时(超过 **24 个「显示宽度」**——中文算 2、字母算 1)这一格右侧出现**逐行的「展开 / 收起」**小按钮,点开看全。**个人项目永远不会出现这个按钮**。展开状态**只在本机内存、按报名项记**,换项目 / 刷新即恢复收起;展开那一行会把列略微撑宽(成绩列右移约 **80px**),点回收起就恢复。
**「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。
| 列 | 操作 |
@@ -142,6 +144,19 @@
**示例文本不是已填内容**:空格子里显示的 `例 1:06.33`(距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`)是**更淡的斜体灰字**,只是告诉你大概怎么写,**不代表这一格已经有成绩**。灰字不会被提交;点发布时如果那一格还是灰字,就按「还没录」自动跳过(本次不发布这一行),不会连累其他已填成绩的发布。过去示例文本长得和真实输入一样,容易看成「我已经填了」。
**「搜姓名 / 号码布」搜索框(人多项目找人不费劲)**:位置在工具栏 **「批次」下拉右边**。
- **即时模糊匹配**:大小写不敏感、首尾空格与全角空格都当空白;号码布按**数字口径**——`007` 搜 `7`、`7` 搜 `007` 都能找到;
- **命中高亮**(只加一层淡底色);
- 输入框旁边显示 `找到 N 条 / 共 M 条`(**M 是当前批次口径的总行数**,不是搜索结果数);
- 清除有三种:输入框右侧的 × 按钮、在输入框里按 `Esc`、「清除搜索」按钮;搜不到时给明确的空状态与提示,不会只剩一张空表;
- 搜索范围是**行的姓名 / 队员名单 / 队员(含号码布)/ 行号码布**,**不含单位名**;
- **换小项 / 换组别会清空搜索**。
⚠️ 界面上也写着这行小字:**「搜索只影响表格显示,不影响发布范围与进度」** —— 筛完再点「发布」,发的仍然是**该小项 + 该组别里所有已填的成绩**(不是只发筛出来的这些);进度条的「已录 x/y」也仍然是**该批次总人数**。
⚠️ **粘贴跟着屏幕走**:搜索状态下按 `Ctrl+V` 是**按屏幕顺序**往下填的(**被筛掉、看不见的行不会被写到**)——**这是有意的**:否则第二条成绩会静默写到看不见的人身上。要按原来的行序粘贴,请先清除搜索。详见 4.3。
右侧「实时积分预览」显示:若现在发布,各单位积分的变化、最大分差、受影响单位数。
### 4.3 键盘流与批量录入
@@ -151,14 +166,15 @@
| `Tab` | 跳到下一个单元格(浏览器原生焦点顺序) |
| `Enter` | 在成绩格之间依次移动 |
| `Ctrl+Enter` | 存草稿 |
| `Alt+Enter` | 发布当前项目 |
| `Alt+Enter` | 打开**发布窗口**(与工具栏「发布」同一个窗口,口径在里面二选一,见 4.5) |
| 「按成绩重排」 | 按成绩从小到大 / 从大到小重排表格行(名次仍以比较值为准) |
| 「下一格」 | 按钮方式移动焦点 |
**从 Excel 粘贴**:复制区域后,在成绩格按 `Ctrl+V`。
- 两列及以上:第一列按单位名称 / 简称匹配行,最后一列作为成绩;
- 单列:从当前行开始向下依次填充。
- 单列:从当前行开始向下依次填充;
- ⚠️ **搜索状态下按屏幕顺序粘贴**:表格正在按搜索框筛选时,粘贴只落在**看得见的行**上(按屏幕顺序),被筛掉的行**不会被写到**——这是有意的:否则第二条成绩会静默写到看不见的人身上。要按原来的行序粘贴,先清除搜索(见 4.2)。
### 4.4 防呆校验
@@ -180,10 +196,22 @@
1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。
- **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。
2. **发布**:点击后弹出确认框,说明本次将发布多少人、还有多少人没成绩,以及影响的单位数与最大分差;确认后这批成绩立即上屏并重算积分榜(若管理员开了**延迟结算**,则只上屏成绩,名次与榜单等该组录完再更新,见 4.6、10.5)。
2. **发布**:点击后弹出**发布窗口**(标题形如 `发布该组:请选择本次的发布口径`),先在**两种口径里二选一**,再点「确认发布」:
| 选项 | 会发生什么 |
| --- | --- |
| **只更新名次(不计分、不发奖牌)** | **默认选中**:名次照常算出来、写进系统,但不产生积分、不发奖牌(分轮次比赛里给预赛 / 复赛用)。选项下面的说明就是 `本次只更新名次:不计分、不发奖牌,榜单分数不变。` |
| **按决赛成绩发布(重算名次并计分、发奖牌、榜单跟着更新)** | 与升级前的「勾上再发布」完全一样:算分、发牌、上榜 |
- 窗口里同时写明本次将发布多少人、还有多少人没成绩;选「按决赛成绩发布」时才会报「影响 N 个单位、最大分差 X 分」(只更新名次时报这两个数是假话,所以不报);
- 确认后这批成绩**立即上屏**:选计分那一项时同时重算积分榜;**只更新名次**时名次照常更新、榜单分数不变(若管理员开了**延迟结算**,则只上屏成绩,名次与榜单等该组录完再更新,见 4.6、10.5);
- **取消 = 什么都不发生**;**每次打开都回到默认那一项**(**不吃上一次的选择**)——这条是故意的:避免「换个小项再点发布,把预赛当成决赛算分」这种最贵的错法;
- `Alt+Enter`(见 4.3)走的是**同一个窗口**;
- 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存;
- 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。
> ⚠️ **成绩审核页(第 5 节)的入口形状不一样**:那里**仍然是发布按钮旁边的常驻勾选框**(勾上才计分发牌)。两页的口径与算法完全一致,只是录入页把口径挪进了弹窗——别以为审核页也改成弹窗了。
### 4.6 分批发布(跑一批发一批)
跑道只有 8 条、一个项目可能要分好几场跑时,**不需要等所有人都跑完**:每跑完一批就发一批,没跑到的先空着。
@@ -444,6 +472,8 @@
- **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜(延迟结算下同样等该组录完才更新名次与榜单),没录的保持未发布,补录后再发一次即可(分批发布,见 4.6);
- **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。
> ⚠️ **本页的发布口径入口与录入页不同(两页别混)**:**本页保持原来的常驻勾选框** —— 发布按钮旁边的 **「以决赛成绩发布(勾选才计分发牌)」**,**默认不勾**,勾上才计分、发牌;旁边徽标随时显示当前是哪种口径(**只更新名次(不计分)** / **计分发布(算分发牌)**)。**录入页(4.5)则是点「发布」后在弹窗里二选一**(默认「只更新名次」)。两页发出去的请求口径完全一样,只是入口形状不同。
> 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效,但**没录成绩的条目会被跳过**——它既不挡发布,也不会被当成 0 分(防呆口径与录入页一致,见 4.4)。
---
@@ -598,14 +628,24 @@
- **保存**:改完模式或项目后点「保存」,提示「实时赛况设置已保存」后大屏按新配置显示;卡片底部注明「当前项目最多 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`)复制到剪贴板,按钮会短暂变成「已复制」;
- **第一个地址带一个二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址;
- **第一个地址带一个常驻二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址;**其余每条地址右侧多一个「二维码」按钮**:**点一下临时显示**那条地址的码,**再点一次 / 按 Esc / 点空白处**都能收起,**同一时刻只留一个**(屏幕小的时候不必为别的网段留一块地方);
- **地址分三类**,卡片会照实把可用地址都列出来(排除的只有回环、未指定、组播、广播这四类对本机之外没有意义的地址):
- **内网地址**(`192.168.x.x`、`10.x.x.x`、`172.16–31.x.x` 这类):路由器 / 交换机分配或手工设好的地址,也是最常见的现场用法,**排在最前**;
- **全局地址**:不在上面这些网段里的地址(运营商专线等),能用就照常列出来,只是**排在私网地址之后**;
@@ -1202,7 +1242,7 @@
> 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。
> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。
> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(**第一条是常驻二维码,其余每条都能点「二维码」按钮临时出码**,见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(带一个自动设备标签,可手动改名),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。
### 17.2 轮播与锁定
@@ -1277,3 +1317,18 @@
**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
### 一句话
@@ -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. **只在"分轮次"的项目上用**:一次就比完的项目照旧**勾上**发布(与以前一样),别漏勾 —— 漏勾的话名次会更新,但**不会计分**,榜上不会有这一项的分(界面上有徽标和在榜口径提示,发布前看一眼就不会错)。
### ④ 升级后 5 分钟核对
+40
View File
@@ -761,3 +761,43 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
3. **「队员名单」只增不减**:导出时会把界面上后来补进这支队的队员一并补上,所以重导后这支队的队员**不会少**,但个别名单的**顺序 / 写法**可能与最初上传的那张表不完全一样。
**一句话**:给人看的用「导出报名名单」;**要拿回去重导,用「导出(汇总·导入格式)」或「导出(分小项·导入格式)」**,导出的文件**不要改表头、不要插删列**。详见[使用手册 §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 个,可「删除」)。
**三类地址的含义**(卡片按类别判定,只排除回环 / 未指定 / 组播 / 广播这四类对本机之外没有意义的地址):
@@ -456,7 +456,7 @@ netsh advfirewall firewall add rule name="Scoreboard 19540" dir=in action=allow
http://<主机IP>:19540/display
```
- 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(第一个地址),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到);
- 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(**第一条是常驻二维码,其余每条点「二维码」按钮也能临时出码**),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到);
- 需要主机设置固定 IP,且设备与主机在同一局域网;
- **主机防火墙要放行入站 19540**(默认端口):**「为所有用户安装」会自动放行**(那一档提权时装好规则),不用再管;**「仅为我安装」不加规则、必须手工放行一次**(命令与说明见 7.5)。装的时候选哪一档,取决于 3.1 里那几行 —— 新版安装器一启动就申请管理员,但只有「为所有用户」那一趟才顺手改防火墙;
- 安卓电视一般常亮,无需额外防熄屏;
+5
View File
@@ -551,6 +551,11 @@ func loadEventRowsWithMembers(db *sql.DB, eventID, categoryID int64, includeDraf
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
// 落不了库)回落到 results.rank,保持名次列不空白。
//
// ⚠️ 这段子查询与 realtime/snapshot.go 的 mainChannelRankJoin、httpapi/live_events.go 的同名常量
// 同源(大屏两个读者),改一处要改三处。优先级**故意不同**:导出这里是 cr 优先(既有口径,
// 由 TestEventRowsUseMainChannelRank 钉住),大屏那边是 results.rank 优先——「只更新名次」的发布
// 只写 results.rank、不重建 channel_ranks,大屏 cr 一优先就会显示上一次的旧名次。
//
// 关联用「cr.id = 该报名在该通道下的最小 id」而不是直接按 (通道, 报名, 组别) JOIN:
// 团体报名挂上队员之后(devdocs/20),一条报名在同一通道下有 1 + N 行名次(报名项自己那份
// 加每个队员一份个人副本),直接 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
}
// 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 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。
//
// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序),
// 但范围固定在这一个小项、且不设条数上限——当前项目要显示完整名单,不能被 20 条截断。
// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序、名次同样是
// results.rank 优先 + channel_ranks 兜底),但范围固定在这一个小项、且不设条数上限——
// 当前项目要显示完整名单,不能被 20 条截断。
func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.EventResultGroup, error) {
rows, err := s.ev.QueryContext(ctx, `
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''),
e.category_id, COALESCE(cat.name, ''),
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,
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
@@ -289,7 +318,7 @@ func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.Eve
JOIN events ev ON ev.id = r.event_id
LEFT JOIN event_categories cat ON cat.id = e.category_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'
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`, eventID)
if err != nil {
+180 -19
View File
@@ -1,32 +1,42 @@
package httpapi_test
import (
"context"
"database/sql"
"net/http"
"strconv"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
type liveEventsBody struct {
Source string `json:"source"`
EventIDs []int64 `json:"eventIds"`
Events []struct {
Event struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"event"`
Reason string `json:"reason"`
Groups []struct {
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
CategoryName string
Rows []struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText string `json:"resultText"`
} `json:"rows"`
} `json:"groups"`
} `json:"events"`
Source string `json:"source"`
EventIDs []int64 `json:"eventIds"`
Events []liveEventBody `json:"events"`
}
type liveEventBody struct {
Event struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"event"`
Reason string `json:"reason"`
Groups []liveGroupBody `json:"groups"`
}
type liveGroupBody struct {
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
CategoryName string `json:"categoryName"`
Rows []liveRowBody `json:"rows"`
}
type liveRowBody struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText string `json:"resultText"`
}
type settingsBody struct {
@@ -203,3 +213,154 @@ func TestDisplayLiveEmptyEvent(t *testing.T) {
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 (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
@@ -16,6 +21,193 @@ type Realtime interface {
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 的变更推给实时层。
// 版本号取请求开始时的最大 id,因此只推送本次写入,不会重复补发历史变更。
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)
}
// 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("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)要能直接拉,故对外只读开放。
mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler))
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 == "" {
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)
}
+51 -10
View File
@@ -30,6 +30,14 @@ type Instance struct {
// 管理端靠它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时为空串。
// 用字符串而不是数字:显示器的 id 由 Electron 给,不保证是小的正整数。
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 的多条连接合并成一个实例。
@@ -37,6 +45,8 @@ type instanceState struct {
id string
page string
screen string
device string
deviceRaw string
connectedAt time.Time
lastHeartbeat time.Time
remoteAddr string
@@ -45,24 +55,45 @@ type instanceState struct {
conns int
}
// registerDisplay 把连接挂到大屏实例上;未带 instanceId 时由服务端生成,
// 保证「刷新页面 = 同一实例」之外的连接也能被登记和查询。
func (h *Hub) registerDisplay(c *client, id, page, screen string) string {
// displayRegistration 是登记一个实例要用的 hello 字段。
// 单独一个结构体而不是直接传 helloPayload:注册表只关心「在哪一页、哪块屏、什么设备」,
// 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 == "" {
id = "display-" + randomID()
}
now := time.Now()
c.mu.Lock()
prev := c.instanceID
c.instanceID = id
c.mu.Unlock()
// 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。
// 复用同一条 id 时 prev == id,引用计数不动——这正是「重复 hello 不许产生新实例」的一半。
if prev != "" && prev != id {
h.releaseDisplayID(prev, c)
}
device := sanitizeDeviceText(reg.device, maxDeviceChars)
deviceRaw := sanitizeDeviceText(reg.deviceRaw, maxDeviceRawChars)
now := time.Now()
h.mu.Lock()
defer h.mu.Unlock()
h.pruneInstancesLocked(now)
@@ -74,11 +105,19 @@ func (h *Hub) registerDisplay(c *client, id, page, screen string) string {
if prev != id {
st.conns++
}
if page != "" {
st.page = page
if reg.page != "" {
st.page = reg.page
}
if screen != "" {
st.screen = screen
if reg.screen != "" {
st.screen = reg.screen
}
// 设备标签只在这次报了非空值时覆盖:老客户端(以及 localStorage 不可用的客户端)
// 不带这两个字段,不该把上一次 hello 报上来的标签抹掉。
if device != "" {
st.device = device
}
if deviceRaw != "" {
st.deviceRaw = deviceRaw
}
st.lastHeartbeat = now
st.remoteAddr = c.remoteAddr
@@ -234,6 +273,8 @@ func (h *Hub) Instances() []Instance {
AckVersion: st.ackVersion,
Slow: st.slow,
Screen: st.screen,
Device: st.device,
DeviceRaw: st.deviceRaw,
})
}
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 (
"encoding/json"
"fmt"
"strings"
"time"
"unicode"
)
// 事件名与协议第 3 节的表格一一对应。
@@ -107,6 +109,64 @@ type helloPayload struct {
Page string `json:"page,omitempty"`
// Screen 是 Console 从 URL 带下来的屏号(screen=<display.id>),局域网浏览器留空。
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 载荷;服务端据此识别慢客户端。
+112 -21
View File
@@ -14,9 +14,28 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
)
// latestResultsLimit 控制快照里「最新成绩」的行数上限,避免大屏首包过大。
// 行会按「小项 + 组别」聚合后再下发,因此组数只会比这个值更少。
const latestResultsLimit = 20
// 快照「最新成绩」的窗口口径:先取最近发布的 K 组,再每组保底取前 R 行。
//
// 不能按「总行数」截断:行是按发布时间倒序取的,而组是按「首次出现」建号的,
// 于是组数组的顺序 = 发布时间倒序——**屏上最旧的那一栏站在窗口尾部**,行数截断的切口
// 只会落进那一组里,把「建号最早的行」切掉。手录名次的项目通常从第 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,也是客户端冷启动的完整状态。
// 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构,
@@ -423,15 +442,82 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
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 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
// 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行;
// 前端按组渲染(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 {
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, ''),
e.category_id, COALESCE(cat.name, ''),
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''),
r.entry_id, r.rank, e.team_id,
rg.group_ts,
r.entry_id, COALESCE(r.rank, cr.rank), e.team_id,
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path,
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
@@ -440,10 +526,13 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
JOIN events ev ON ev.id = r.event_id
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN teams t ON t.id = e.team_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE 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
LIMIT ?`, latestResultsLimit)
LEFT JOIN athletes a ON a.id = e.athlete_id`+mainChannelRankJoin+`
JOIN bounded b ON b.rid = r.id
JOIN recent_groups rg ON rg.event_id = b.event_id AND rg.category_id = b.category_id
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 {
return fmt.Errorf("读取最新成绩: %w", err)
}
@@ -460,18 +549,20 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
eventID, categoryID int64
eventCode, eventName string
categoryName string
publishedAt string
row dto.EventResultRow
rank sql.NullInt64
value sql.NullFloat64
athleteID sql.NullInt64
athleteName string
shortName string
logoPath sql.NullString
memberNames string
isTie int
// groupPublishedAt 是这一**组**的发布时间(组内最新一条),来自 SQL 的 group_ts,
// 不是这一行自己的时间——组内按名次截断后,第一行的时间不再等于组的最新时间。
groupPublishedAt string
row dto.EventResultRow
rank sql.NullInt64
value sql.NullFloat64
athleteID sql.NullInt64
athleteName string
shortName string
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,
&athleteID, &athleteName, &memberNames,
&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,
CategoryID: categoryID,
CategoryName: categoryName,
PublishedAt: nilIfEmpty(publishedAt),
PublishedAt: nilIfEmpty(groupPublishedAt),
Rows: []dto.EventResultRow{},
})
}
+373
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"testing"
"time"
"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">
<li v-for="(line, index) in details" :key="index">{{ line }}</li>
</ul>
<!--
调用方自定义的那一问(录入页的「本次怎么发」二选一挂在这里)。
做成插槽而不是再弹一个窗:口径属于这次确认的一部分,分开问会多一次点击、
也多一个「关掉第一个弹窗之后什么都没发生」的中间态。
-->
<slot></slot>
<!--
原因输入:pending 时用 readonly 而不是 disabled——请求已经在服务端跑了,
这时候改文字不会被提交(弹窗本身也锁死了,关不掉),但不该把已填的内容抹成灰。
@@ -87,21 +87,62 @@ export const COLUMN_ALIASES: Record<ImportColumnName, readonly string[]> = {
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、转小写。
* 与服务端 normalizeImportHeader 同一口径——现场的表是中文 Excel 存的,
* 列名常带全角空格 / 被输入法写成全角字母 / 第一格粘着 BOM。
*
* 与搜索框共用同一份折叠实现(foldSearchText):口径分两份写,迟早有一边忘了改。
*/
export function normalizeHeaderCell(raw: string): string {
let folded = ''
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()
return foldSearchText(raw)
}
/** 认一格表头,返回规范列名;认不出的(多余列、备注列)返回 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(不能把名单筛空)。
*
* 搜索框是文本框,这里只做字符串比较;**不要**把它的取值喂给 `type="number"` 的输入框
* (那种框的 v-model 会被 Vue 转成 number,清空才留 '',字符串方法会在渲染期抛错)。
* 录入表格的搜人框(pages/entrySearch.ts)也走这一份:姓名 / 号码布的口径只有这一处实现,
* 两处各写一份迟早会分叉(一边能搜号码布、另一边不能)。
*/
export function athleteMatchesKeyword(
athlete: { name: string; employeeNo?: string | null; bib?: string | null },
keyword: string,
): boolean {
const needle = keyword.trim().toLowerCase()
if (needle === '') return true
return (
athlete.name.toLowerCase().includes(needle) ||
(athlete.employeeNo ?? '').toLowerCase().includes(needle) ||
(athlete.bib ?? '').toLowerCase().includes(needle)
textMatchesKeyword(athlete.name, keyword) ||
textMatchesKeyword(athlete.employeeNo ?? '', keyword) ||
bibMatchesKeyword(athlete.bib, keyword)
)
}
+294 -8
View File
@@ -12,13 +12,24 @@ import { settingsApi } from '../components/system/systemApi'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
import {
ALIAS_CLEAR_HINT,
ALIAS_RENAME_HINT,
aliasPayload,
instanceDisplayName,
instanceIdNote,
instanceTooltip,
} from './displayInstanceLabel'
import {
copyText,
isLanQrKeepTarget,
lanDisplayLinks,
lanEmptyNote,
manualAddressStorage,
parseManualAddress,
readManualAddresses,
showsQrButton,
toggleQr,
writeManualAddresses,
} from './lanDisplay'
@@ -328,6 +339,75 @@ function onVisibilityChange(): void {
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)
@@ -412,19 +492,107 @@ const lanPortMissing = computed(() => lanInfo.value !== null && lanInfo.value.po
const lanManualEnabled = computed(() => lanInfo.value !== null && !lanPortMissing.value)
/**
* 第一个地址的小二维码。
* 给一条地址出二维码 SVG。
*
* 只给第一条地址出码:卡片上摆一排二维码没有意义,现场也只需要一个能随手扫的入口。
* 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 first = lanLinks.value[0]
if (first === undefined) return null
const size = encodeQr(first.url).size
const scale = Math.max(1, Math.round(QR_PIXELS / (size + QR_MARGIN * 2)))
return qrToSvg(first.url, { scale, margin: QR_MARGIN, ecc: 'M' })
return qrSvgFor(first.url)
})
/**
* 当前临时展开的是哪一条地址,`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> {
// 身份还没就绪时不要「把访客理由写进 lanProblem 就完事」:auth 是异步加载的,
// 挂载那一刻 readOnly 必然为 true,写死之后即使随后拿到管理员身份也没有东西再触发本函数
@@ -486,6 +654,9 @@ onBeforeUnmount(() => {
clearTimeout(copiedTimer)
copiedTimer = null
}
// 临时二维码的两个监听也在这里兜底摘掉:页面被切走时如果还开着码,watch 不会替我们摘
// (组件卸载时状态没人再改),漏摘会随每次进页面越积越多。
detachLanQrDismiss()
document.removeEventListener('visibilitychange', onVisibilityChange)
})
@@ -998,6 +1169,22 @@ async function send(
<MxIcon :name="copiedUrl === item.url ? 'check' : 'copy'" :size="14" />
{{ copiedUrl === item.url ? '已复制' : '复制' }}
</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 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-note">手机扫码直接打开</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 位读不到 / 按默认路由推断)照常给链接与二维码——它们往往
真能用——只是说清这条是怎么来的:现场遇到「扫了打不开」时才知道换下一条,
@@ -1072,9 +1268,18 @@ async function send(
</p>
<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">
<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'">
{{ instance.online ? '在线' : '离线' }}
</span>
@@ -1088,6 +1293,50 @@ async function send(
</div>
<div class="display-sub">最后心跳:{{ formatDateTime(instance.lastHeartbeat) }}</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 class="display-actions">
<select
@@ -1106,6 +1355,16 @@ async function send(
>
{{ targetPending === `instance:${instance.id}` ? '下发中…' : '切到此页' }}
</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>
@@ -1113,6 +1372,9 @@ async function send(
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 全部大屏页面 {{ pageLabel(currentPage) }}</p>
<p v-if="instancesUpdatedAt !== null" class="muted">最近一次刷新:{{ formatDateTime(instancesUpdatedAt) }}</p>
<p class="muted">「落后」= 该实例确认的版本落后(大屏可能在卡或网络差);离线实例会在服务端保留一段时间,便于排查。</p>
<p class="muted">
名字优先显示手动改的(点「改名」),其次是浏览器报上来的设备信息,都没有才显示实例 id;把鼠标停在条目上能看到原始 UA 与实例 id。
</p>
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
</div>
</div>
@@ -1429,11 +1691,35 @@ async function send(
font-size: 13px;
font-weight: 600;
}
.instance-id {
.instance-name {
overflow-wrap: anywhere;
}
/* 名字已经是别名 / 设备标签时,实例 id 降级成一行小字(切页指令点名的就是它)。 */
.instance-id-note {
font-family: var(--mx-mono);
font-size: 12px;
font-weight: 400;
color: var(--mx-t3);
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,
.screen-page-select {
width: 132px;
+379 -60
View File
@@ -52,7 +52,23 @@ import {
teamRanksPayload,
type TeamRankRow,
} 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 {
addShareDraftTeam,
removeShareDraftTeam,
@@ -139,15 +155,17 @@ import {
rememberEntryEventId,
} from './entryInitialEvent'
import {
FINAL_PUBLISH_LABEL,
FINAL_PUBLISH_TITLE,
PREVIEW_RANK_ONLY_HINT,
PUBLISH_BUTTON_TITLE,
PUBLISH_OPTIONS,
boardModeNote,
publishModeBadge,
defaultPublishChoice,
openPublishChoice,
publishChoiceIsFinal,
publishModeConfirm,
publishModeHint,
publishedPointsOnBoard,
rankOnlyOf,
type PublishChoice,
} from './entryPublish'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import EntryPicker from '../components/baseData/EntryPicker.vue'
@@ -268,12 +286,15 @@ const clearDialog = ref(false)
const clearFilter = ref<HeatFilter>('all')
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 settling = ref(false)
@@ -426,6 +447,37 @@ const nextHeat = computed(() => maxHeat(categoryRows.value) + 1)
/** 表格实际显示的行:组别 + 批次两层筛选;统计、防呆与发布口径都跟着它走。 */
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(() =>
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 hiddenIssueCount = computed(() => Math.max(0, errors.value.length + warnings.value.length - listedIssues.value.length))
/**
* 发布确认框的标题:口径在弹窗里选,标题只问「这一次怎么发」。
*
* 不按选中的那一项改标题:选项就在标题下面一行,标题再跟着变反而像已经替人做了决定。
*/
const publishDialogTitle = computed(() => `发布${scopeWord.value}:请选择本次的发布口径`)
/** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */
const publishMessage = computed(() => {
// 通道配置本身就出不来分时,「影响 0 个单位、最大分差 0 分」是句假话(服务端会 409),
@@ -1073,16 +1132,18 @@ function issueFor(entryId: number): Issue | undefined {
}
/**
* 切换组别:批次筛选与行选择都回到原点。
* 切换组别:批次筛选、行选择与搜索都回到原点。
*
* 批次是「组别之下」的概念,换组别后旧批次号没有意义;选择留着会让人误以为
* 还能对看不见的行做批量设置。
* 还能对看不见的行做批量设置;搜索留着则只会得到一张空表。
*/
function selectCategory(id: number): void {
if (selectedCategoryId.value === id) return
selectedCategoryId.value = id
selectedHeat.value = 'all'
selectedEntryIds.value = []
// 搜索与批次同源:换组别后行完全换了一批,留着关键字只会得到一张空表。
clearSearch()
void loadTeamRanks()
}
@@ -1116,6 +1177,8 @@ async function loadEvent(id: number): Promise<void> {
selectedCategoryId.value = event.categories?.[0]?.id ?? null
selectedHeat.value = 'all'
selectedEntryIds.value = []
// 搜索同样归零:换小项等于换一份名单(与批次筛选、行选择同一个理由)。
clearSearch()
allRows.value = board.entries.map(toRow)
// 记下「上次录入的是哪个小项」:下次进录入页要选回它(按 id,改名不影响)。
// 只在真的加载成功之后写——加载失败还留着旧项目,不该把记忆改掉。
@@ -1516,6 +1579,25 @@ function athleteCellTitle(row: EditableRow): string {
: `${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> {
athleteEditor.value = {
entryId: row.entryId,
@@ -1768,6 +1850,9 @@ async function saveDraft(silent = false): Promise<boolean> {
function requestPublish(): void {
if (selectedEventId.value === null) return
// 弹窗已经开着时不再走一遍「打开弹窗」:那会把操作员刚在弹窗里选好的口径复位成默认。
// Alt+Enter 是本页级的快捷键(弹窗开着也收得到),这一步防的就是它连按两下。
if (publishDialog.value) return
if (data.isMock) {
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
@@ -1789,6 +1874,9 @@ function requestPublish(): void {
toast.error(`有 ${errors.value.length} 处校验未通过`, errors.value[0]?.message ?? null)
return
}
// 口径在弹窗里选,且**每次都从默认的「只更新名次」起步**:上一次(哪怕是换这个小项之前)
// 选过「按决赛成绩发布」也不沿用,否则会把一批预赛成绩当成决赛算分。
publishChoice.value = openPublishChoice(publishChoice.value)
publishDialog.value = true
}
@@ -1813,9 +1901,9 @@ async function confirmPublish(): Promise<void> {
rankOnly: rankOnlyOf(finalPublish.value),
})
publishDialog.value = false
// 勾选是一次发布的口径,不是页面设置:发完就复位成「只更新名次」,
// 口径是一次发布的属性,不是页面设置:发完就复位成「只更新名次」,
// 免得换个小项再点发布时把预赛成绩当成决赛算分。
finalPublish.value = false
publishChoice.value = defaultPublishChoice()
await reloadAfterSettlementChange()
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
const deferred = settlement.value?.liveOnPublish === false
@@ -2403,6 +2491,11 @@ function onCellKeydown(event: KeyboardEvent, element: HTMLElement): void {
function onPageKeydown(event: KeyboardEvent): void {
if (!event.ctrlKey && !event.altKey) 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()
if (saving.value || publishing.value || unpublishing.value) return
if (event.altKey) requestPublish()
@@ -2419,28 +2512,34 @@ function onBeforeUnload(event: BeforeUnloadEvent): void {
/**
* Excel 粘贴:两列及以上时,第一列按单位名匹配行,最后一列作为成绩;
* 单列时从当前行往下顺序填充(07 第 2 节第 4 条)。
*
* 顺序填充走的是**表格上真正显示的那一份**(displayRows = 批次 + 搜索),不是批次口径的
* visibleRows:粘贴本来就是「照着屏幕上从上往下抄」,搜索把行藏起来之后如果还按批次口径往后数,
* 第二条成绩就会落到看不见的人头上——那是静默改错人,比「少填几行」严重得多。
* 发布 / 进度 / 勾选 / 清空那些口径不受影响,它们读的仍是 visibleRows。
*/
function onPaste(event: ClipboardEvent, startEntryId: number): void {
const text = event.clipboardData?.getData('text/plain') ?? ''
if (text.trim() === '') return
// 已发布的行现在是 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
event.preventDefault()
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) {
const columns = line.split('\t').map((column) => column.trim())
if (cursor < 0 || cursor >= visibleRows.value.length) break
let target = visibleRows.value[cursor]
if (cursor < 0 || cursor >= rows.length) break
let target = rows[cursor]
if (target === undefined) break
if (columns.length >= 2) {
const key = columns[0] ?? ''
const matched = visibleRows.value.find(
const matched = rows.find(
(row) => row.teamName.includes(key) || (row.teamShort !== '' && key.includes(row.teamShort)),
)
if (matched !== undefined) target = matched
@@ -2448,7 +2547,7 @@ function onPaste(event: ClipboardEvent, startEntryId: number): void {
} else {
target.resultText = columns[0] ?? ''
}
cursor = visibleRows.value.indexOf(target) + 1
cursor = rows.indexOf(target) + 1
}
toast.info(`已粘贴 ${lines.length} 行`)
}
@@ -2510,27 +2609,9 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<template #actions>
<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
class="mx-btn mx-btn-secondary"
type="button"
@@ -2544,7 +2625,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || publishing || data.isMock || pendingRows.length === 0"
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : publishModeHint(finalPublish)"
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : PUBLISH_BUTTON_TITLE"
@click="requestPublish"
>
<MxIcon name="push" :size="15" /> 发布<span v-if="hasPublished && pendingRows.length > 0">本批</span>
@@ -2619,6 +2700,38 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</option>
</select>
</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 class="toolbar-right">
<span class="entry-counts">
@@ -2859,13 +2972,13 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<div v-if="hasPublished" class="published-note">
<MxIcon name="lock" :size="15" />
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
录完再点一次「发布本批」即可;勾选「以决赛成绩发布」时名次与积分按已发布成绩整体重算。
录完再点一次「发布本批」即可;发布时在弹窗里选「按决赛成绩发布」,名次与积分按已发布成绩整体重算。
要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。
</div>
<!--
本组在榜上的口径(预赛还是决赛):只排名 / 已计分一眼看出来。
判据是「已发布成绩有没有拿到分」,也就是榜面上的事实——系统不记「上次勾了什么」,
判据是「已发布成绩有没有拿到分」,也就是榜面上的事实——系统不记上次选了哪种口径,
而榜单本来就只认流水。它同时点破了只更新名次发布后的「名次变了、分还是旧的」。
-->
<div
@@ -2888,7 +3001,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
type="checkbox"
:checked="allVisibleSelected"
:disabled="visibleRows.length === 0"
:title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行)`"
:title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行${searching ? ';搜索不改变勾选与批量操作的范围,这里仍是全部行' : ''})`"
@change="toggleAllVisible"
/>
</th>
@@ -2914,7 +3027,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</thead>
<tbody>
<tr
v-for="row in visibleRows"
v-for="row in displayRows"
:key="row.entryId"
:class="{
'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>
</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):只有纯团体报名才有这个按钮——个人报名的身份就是那一个运动员。
老数据(没有队员关联)的文案一个字符都没变,这里只是多了一个改的入口。
@@ -3132,6 +3267,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<span v-if="rowActionsOf(row).length === 0" class="muted">—</span>
</td>
</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">
<td colspan="12">
<div class="mx-empty">
@@ -3170,10 +3320,10 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</div>
<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 }}
</p>
<p class="preview-hint">
@@ -3320,17 +3470,47 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</section>
</div>
<!--
发布确认框,也是本页唯一的发布入口(工具栏按钮 / Alt+Enter 都走 requestPublish):
口径就在这一个弹窗里问完 —— 上面那组二选一挂在 ConfirmDialog 的默认插槽里,
不再另开第二个弹窗,也不在工具栏上留常驻勾选框。
-->
<ConfirmDialog
:open="publishDialog"
tone="warning"
:title="finalPublish ? '确认按决赛成绩发布该组?' : '确认发布该组(只更新名次)?'"
:title="publishDialogTitle"
:message="publishMessage"
:details="warnings.map((issue) => issue.message)"
confirm-text="确认发布"
:pending="publishing"
@confirm="confirmPublish"
@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 的新增纪录弹窗)自己套一层,
@@ -3806,6 +3986,56 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
width: auto;
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 {
display: flex;
@@ -3959,24 +4189,60 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
color: var(--mx-green);
font-size: 13px;
}
/* 发布口径勾选:贴着发布按钮,未勾(只更新名次)用警示色,勾上再用主色。 */
.publish-final-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
/*
发布弹窗里的口径二选一:挂在 ConfirmDialog 的默认插槽上(作用域样式跟着插槽内容走)。
与「平均值口径」那一组同一种观感:一个选项一行,标题 + 说明,选中项用主色。
*/
.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;
color: var(--mx-t2);
cursor: pointer;
user-select: none;
}
.publish-final-toggle input {
.publish-choice-option input {
margin-top: 2px;
flex: none;
cursor: pointer;
}
.publish-final-toggle input:disabled {
.publish-choice-option input:disabled {
cursor: not-allowed;
}
.publish-mode-badge {
white-space: nowrap;
.publish-choice-text {
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;
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 {
margin-left: 6px;
@@ -4519,6 +4831,13 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
font-size: 11px;
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 样式,
作用不到这里,所以在这边按同一份排版自己写一份)。 */
.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'
/**
* 「以决赛成绩发布」勾选项的口径与文案。
* 发布口径(只更新名次 / 按决赛成绩发布)的口径与文案。
*
* 现场只有最后一轮(决赛)的成绩算分,所以发布入口多了一个勾选:
* 不勾(默认)= 本次只更新名次(预赛也要排名次),不计分、不发奖牌、榜单分数不变;
* 勾上 = 与改动前完全一样:算分、发牌、上榜。
* 现场只有最后一轮(决赛)的成绩算分,所以发布入口多了一问:
* 只更新名次(默认)= 本次只更新名次(预赛也要排名次),不计分、不发奖牌、榜单分数不变;
* 按决赛成绩发布 = 与改动前完全一样:算分、发牌、上榜。
*
* 单独成一个模块(而不是散在页面里),是为了让「勾选状态 → 请求字段 → 各处文案」这条链子
* 这一问**在录入页点「发布」之后弹出的确认框里问**(原来挂在工具栏上的常驻勾选框已撤掉):
* 口径只在「这一次发布」上有意义,而弹窗本身就带着上下文(发的是哪一组、会影响什么)。
* 默认值仍是「只更新名次」,并且**每次打开弹窗都从默认值起步**,绝不沿用上一次的选择——
* 沿用会把一批预赛成绩当成决赛算分,那是这个功能最贵的错法。
*
* 单独成一个模块(而不是散在页面里),是为了让「弹窗选项 → 请求字段 → 各处文案」这条链子
* 能脱离组件单测:文案说反了现场就会按错的口径发布,这是本功能最容易出错的地方。
*/
/** 勾选框文案:一句话说清勾上之后会发生什么。 */
/**
* 审核页(成绩审核 → 发布整组)仍然是常驻勾选框,这两个常量归它用。
* 录入页已改成弹窗里二选一,不再引用它们。
*/
export const FINAL_PUBLISH_LABEL = '以决赛成绩发布(勾选才计分发牌)'
/** 勾选框的悬停说明:两种口径各会发生什么。 */
export const FINAL_PUBLISH_TITLE =
'勾上 = 本次按决赛成绩发布:重算名次并计分、发奖牌、榜单跟着更新;不勾(默认)= 本次只更新名次,不计分、不发奖牌,榜单分数不变。'
/** 请求字段:不勾就是「只更新名次」。 */
/** 请求字段:没选「按决赛成绩发布」就是「只更新名次」。 */
export function rankOnlyOf(finalPublish: boolean): boolean {
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 {
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 {
return finalPublish ? '计分发布(算分发牌)' : '只更新名次(不计分)'
}
@@ -47,14 +120,20 @@ export function publishModeConfirm(finalPublish: boolean, pointsOnBoard: number)
if (finalPublish) {
return '本次按决赛成绩发布:重算名次并计分发奖牌,榜单立刻跟着变。'
}
const base = '本次只更新名次:不计分、不发奖牌,榜单分数不变(录入页预览里的分差,是勾选「以决赛成绩发布」时的效果)。'
const base =
'本次只更新名次:不计分、不发奖牌,榜单分数不变(录入页预览里的分差,是选「按决赛成绩发布」时的效果)。'
if (pointsOnBoard <= 0) return base
return `${base}注意:本组已有 ${formatPoints(pointsOnBoard)} 分在榜(上次按决赛发布),名次会按这次成绩更新,但那份积分与奖牌会原样保留、不随名次变动。`
}
/** 「实时积分预览」面板在只更新名次时的口径提示(那里的分差这次不会发生)。 */
/**
* 「实时积分预览」面板的口径提示(选「只更新名次」时那里的分差不会发生)。
*
* 预览是在**发布之前**看的,此刻还没有人做过选择,所以这句话不能写「当前是只更新名次」,
* 只能写成「发布时在弹窗里选哪一项会怎样」。
*/
export const PREVIEW_RANK_ONLY_HINT =
'当前是「只更新名次」:这次发布会按上面的名次落库,但不计分——下列分差是勾选「以决赛成绩发布」时的效果。'
'发布时在弹出的窗口里选「只更新名次」(默认)时,这次只按上面的名次落库、不计分——下列分差是选「按决赛成绩发布」时的效果。'
/** 只看已发布成绩在榜上的分(草稿还没结算,不算)。 */
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:没有在榜成绩就没有口径可谈,别多一行噪声。
* 判据是「已发布成绩有没有从流水拿到分」,而不是「上次勾没勾」——系统不记「上次勾了什么」,
* 判据是「已发布成绩有没有从流水拿到分」,而不是「上次选了哪一种」——系统不记上次的选择,
* 而榜单上的分与牌本来就只认流水,这句话描述的正是榜面事实。
*/
export function boardModeNote(publishedCount: number, pointsOnBoard: number): string | null {
if (publishedCount <= 0) return null
if (pointsOnBoard <= 0) {
return '本组在榜上只有名次(上次是按「只更新名次」发布的,不计分):要算分、发奖牌,请勾选「以决赛成绩发布」再发布一次。'
return '本组在榜上只有名次(上次是按「只更新名次」发布的,不计分):要算分、发奖牌,请在发布弹窗里选「按决赛成绩发布」再发布一次。'
}
return `本组在榜上是已计分的成绩(已有 ${formatPoints(pointsOnBoard)} 分来自上一次按决赛发布):不勾「以决赛成绩发布」再发一次只会更新名次,这些积分与奖牌不会被改动。`
return `本组在榜上是已计分的成绩(已有 ${formatPoints(pointsOnBoard)} 分来自上一次按决赛发布):发布时选「只更新名次」再发一次只会更新名次,这些积分与奖牌不会被改动。`
}
+79
View File
@@ -4,6 +4,10 @@
* 抽出来的原因:号码布原本只在左边单独一列,录成绩时眼睛要在姓名与号码之间来回跳,
* 所以姓名紧后面要再显示一次号码布;而「姓名怎么回落」与「号码布为空时不占位」
* 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。
*
* 另一件事是多人团体 / 接力的名单(`张伟(A001)、李强(A002)、…`)会很长,
* 收起时要限宽截断、长了才给「展开 / 收起」按钮;判据同样必须是纯函数,
* 不然「什么时候该出现按钮」就只能靠肉眼在页面上试。
*/
import { entryRosterText } from '@/shared'
@@ -39,3 +43,78 @@ export function athleteCellName(row: EntryNameLike): string {
export function inlineBibLabel(bib: string | null | undefined): string {
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}。`
}
/**
* 点「二维码」按钮之后,当前该展开的是哪一条地址(`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 里的键。带前缀,免得和同域下别的页面撞。 */
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 assert from 'node:assert/strict'
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('有号码布:原样展示(去掉首尾空白)', () => {
@@ -29,12 +38,140 @@ test('姓名主文本:个人行用姓名,团体 / 接力行回落队员名
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
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.match(cell, /<span v-if="inlineBibLabel\(row\.bib\)"/, '号码布必须带 v-if,空值不渲染')
assert.match(cell, /class="bib-inline mono"/, '号码布要小号、弱化、等宽')
assert.match(cell, /class="athlete-cell-name"/, '姓名文本在自己的元素里,才能单独限宽截断')
})
test('原来的「号码」列保留,没有被这次改动删掉', () => {
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/)
})
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="openLanQrIp === item\.ip && openLanQrSvg !== null"/)
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(含非安全上下文回退),失败有提示', () => {
+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 { readFileSync } from 'node:fs'
import {
FINAL_OPTION_TITLE,
FINAL_PUBLISH_LABEL,
PREVIEW_RANK_ONLY_HINT,
PUBLISH_BUTTON_TITLE,
PUBLISH_OPTIONS,
RANK_ONLY_OPTION_TITLE,
boardModeNote,
defaultPublishChoice,
openPublishChoice,
publishChoiceIsFinal,
publishModeConfirm,
publishModeHint,
rankOnlyOf,
@@ -11,22 +19,77 @@ import {
} from '../pages/entryPublish'
/**
* 「以决赛成绩发布」勾选项的口径(主人的新能力):
* 不勾(默认)= 本次发布只更新名次,不计分、不发奖牌;
* 勾上 = 与改动前完全一样(算分、发牌、上榜)。
* 发布口径(只更新名次 / 按决赛成绩发布)。
* 本次改动:**录入页把常驻勾选框换成了「点发布 → 弹窗里二选一」**:
* 只更新名次(默认)= 本次发布只更新名次,不计分、不发奖牌;
* 按决赛成绩发布 = 与改动前完全一样(算分、发牌、上榜)。
*
* 两件事必须钉住:勾选状态真的进了请求(rankOnly),以及界面上任一处都不许把
* 「只更新名次」说成会算分(确认框 / 常驻提示 / 预览面板)。
* 三件事必须钉住:弹窗里选的那一项真的进了请求(rankOnly)、**每次打开弹窗都回到默认的
* 「只更新名次」**(不沿用上一次的选择),以及界面上任一处都不许把「只更新名次」说成会算分
* (弹窗选项 / 确认框正文 / 在榜口径提示 / 预览面板)。
*/
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(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('确认框写明本次是计分发布还是只更新名次', () => {
const scored = publishModeConfirm(true, 0)
assert.match(scored, /按决赛成绩发布/)
@@ -50,13 +113,13 @@ test('只更新名次时,本组已有在榜积分必须点名说清「不会
assert.doesNotMatch(publishModeConfirm(true, 18), /保留/)
})
test('勾选框旁的常驻提示:不勾时明确说「不计分」', () => {
test('两种口径的说明:只更新名次时明确说「不计分」', () => {
assert.match(publishModeHint(false), /只更新名次/)
assert.match(publishModeHint(false), /不计分/)
assert.match(publishModeHint(true), /算分/)
})
test('勾选框文案本身就说清「勾选才计分发牌」', () => {
test('审核页的常驻勾选框文案不变:仍说清「勾选才计分发牌」', () => {
assert.match(FINAL_PUBLISH_LABEL, /决赛/)
assert.match(FINAL_PUBLISH_LABEL, /勾选才计分/)
})
@@ -85,28 +148,68 @@ test('在榜积分只数已发布的成绩,草稿不算', () => {
)
})
test('录入页:勾选框默认不勾,且勾选状态进了发布请求', () => {
assert.match(SOURCE, /const finalPublish = ref\(false\)/)
assert.match(SOURCE, /v-model="finalPublish"/)
assert.match(SOURCE, /rankOnly: rankOnlyOf\(finalPublish\.value\)/)
// 发布请求里不能写死 false(写死 = 永远计分发布,勾选框变成摆设)。
assert.doesNotMatch(SOURCE, /rankOnly: false/)
/**
* 下面这几条是**源码级接线断言**:`.vue` 跑不进 `node --test`,而「勾选框撤了、弹窗没接上」
* 这种改到一半的回归没有任何测试拦得住 —— 它既不是类型错误,也不影响构建。
*/
const TOOLBAR = SOURCE.slice(SOURCE.indexOf('<template #actions>'), SOURCE.indexOf('</PageHeader>'))
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('录入页:勾选框与醒目提示就贴在发布按钮边上', () => {
assert.match(SOURCE, /class="publish-final-toggle"/)
assert.match(SOURCE, /publishModeHint\(finalPublish\)/)
// 不勾时必须给出可读的「只更新名次」标记(警示色),不能只靠勾选框本身。
assert.match(SOURCE, /publishModeBadge/)
test('录入页:两个选项都挂在那个发布确认框里(不是第二个弹窗)', () => {
assert.notEqual(PUBLISH_DIALOG.length, 0, '确认框那一段得能切出来')
assert.match(PUBLISH_DIALOG, /v-for="option in PUBLISH_OPTIONS"/)
assert.match(PUBLISH_DIALOG, /v-model="publishChoice"/)
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('录入页:确认框用同一套文案,且把在榜积分传进去', () => {
assert.match(SOURCE, /publishModeConfirm\(finalPublish\.value, pointsOnBoard\.value\)/)
assert.match(SOURCE, /const publishDialogTitle = computed/)
})
test('录入页:实时积分预览要说明「不勾时这些分差不会发生」', () => {
assert.match(SOURCE, /preview-rank-only-hint/)
assert.match(MODULE, /勾选「以决赛成绩发布」/)
test('录入页:实时积分预览要说明「发布时选只更新名次时这些分差不会发生」', () => {
// 口径要等点发布才选,所以这句常驻显示,不能再挂在某个勾选状态上。
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', () => {
@@ -55,8 +55,10 @@ const eventNames = computed(() => new Map(events.value.map((event) => [event.id,
/**
* 「当前项目」由服务端判定(GET /api/display/live:人工指定 → 排期命中 → 最近发布)。
*
* 拉到过结论就用它(不受快照 20 行截断,auto/manual 都一样);还没成功过(老服务端、断网首屏、
* 示例数据)才退回「人工指定 + 快照分组」的兜底素材,有总比没有强。
* 拉到过结论就用它(这个接口按小项给**全量**的已发布成绩、**不设条数上限**,不受快照那个
* 「每组保底前 10 行、最多 8 组」窗口(`latestResultsGroupLimit` / `latestResultsRowsPerGroup`)限制,
* auto/manual 都一样);还没成功过(老服务端、断网首屏、示例数据)才退回「人工指定 + 快照分组」
* 的兜底素材,有总比没有强。
*/
const sources = computed<LiveEventSource[]>(() =>
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 里能拿到的组。
*
* 只在老服务端(没有 /api/display/live)、断网首屏、示例数据这三种情况下走这条路;
* 快照的 latestResults 受 20 行上限约束,所以它只能算「有总比没有强」。
* 快照的 latestResults 窗口是**每组保底前 10 行、最多 8 组**(`realtime/snapshot.go` 的
* `latestResultsGroupLimit` / `latestResultsRowsPerGroup`),所以它只能算「有总比没有强」。
* 快照里没有该项目时保留这一栏(groups 为空),由页面显示占位而不是静默少一栏。
*/
export function fallbackLiveSources(
+3 -2
View File
@@ -77,8 +77,9 @@ export interface EventResultColumn {
* 第 1 名不在先到的那 6 行里就整行不显示、剩下的名次看着是跳的(3、6、9、14、15、16)。
* 名次只能由名次自己说了算(实时赛况页的 liveRows 同一套口径)。
*
* ⚠️ 服务端**根本没下发**的行补不回来:那是快照 20 行窗口(`realtime/snapshot.go` 的
* `latestResultsLimit`)截断的问题,不在这层;这一层只保证「拿到的行按名次画」。
* ⚠️ 服务端**根本没下发**的行补不回来:那是快照「每组保底前 10 行、最多 8 组」窗口
* (`realtime/snapshot.go` 的 `latestResultsGroupLimit` / `latestResultsRowsPerGroup`)截断的问题,
* 不在这层;这一层只保证「拿到的行按名次画」。
*
* 并列名次保持原有先后(`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 { entryRosterIndex } from './members'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { deviceLabel, deviceLabelSource, resolveDeviceLabel } from './deviceLabel'
import { instanceIdStorage, resolveDisplayIdentity } from './instanceId'
import { applyDisplayFonts } from './fonts'
import { liveRefreshDelay } from './live'
import { createMockAthletes, createMockSnapshot } from './mock'
@@ -686,13 +688,24 @@ watch(
function connect(): void {
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({
role: 'display',
displayKey: options.value.instance ?? options.value.screen ?? undefined,
instanceId: options.value.instance ?? undefined,
displayKey: identity.displayKey,
instanceId: identity.instanceId,
// Console 从 URL 带下来的屏号(screen=<显示器 id>),让管理端把本机显示器与在线实例对上;
// 局域网浏览器直接开 /display 时没有它,留空。
screen: options.value.screen ?? undefined,
// 设备标签:管理端列表拿它当主行(alias 优先),原始 UA 留给悬浮提示排障。
device: label.device,
deviceRaw: label.deviceRaw,
page: page.value,
initialVersion: version.value,
onSnapshot: (message) => {
@@ -742,6 +755,9 @@ async function bootstrap(): Promise<void> {
hydrateFromCache()
connect()
// 设备标签的高熵值(真实系统版本)只能异步拿,拿到了就补一条 hello;拿不到(内网 http 会被
// 直接拒绝)什么都不做,列表主行用同步那份标签。
void refineDeviceLabel()
const restOk = await loadRest()
if (!restOk && version.value === null) activateMock()
@@ -749,6 +765,17 @@ async function bootstrap(): Promise<void> {
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 {
stopPageRotation()
clearDetailTimer()
+45 -1
View File
@@ -435,12 +435,28 @@ export interface DisplayInstance {
slow: boolean
/** 屏号;局域网浏览器直接开 /display 时是空串。 */
screen: string
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`)。
*
* 老客户端 / 老服务端没有这个字段(omitempty),此时为 undefined——列表退回显示 id。
*/
device?: string
/** 原始 UA,悬浮提示排障用;同样可能缺字段。 */
deviceRaw?: string
/** 操作员手动设的名字(机器级,跟着实例 id 走);没设过时为 undefined。 */
alias?: string
}
export interface DisplayApi {
command(command: DisplayCommand): Promise<void>
/** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */
instances(): Promise<DisplayInstance[]>
/**
* 给某个实例设置 / 清除别名(`PUT /api/display/instances/{id}/alias`,录入员及以上)。
*
* 空串(或只空白)表示清除,实例回到自动标签;返回该实例的最新记录(形状与列表元素一致)。
*/
setAlias(id: string, alias: string): Promise<DisplayInstance>
/** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */
live(): Promise<LiveEvents>
}
@@ -722,7 +738,7 @@ function readDisplayInstance(value: unknown): DisplayInstance | null {
const id = readString(value, 'id')
if (id === null || id === '') return null
const ackVersion = value['ackVersion']
return {
const instance: DisplayInstance = {
id,
online: value['online'] === true,
page: readString(value, 'page') ?? '',
@@ -733,6 +749,23 @@ function readDisplayInstance(value: unknown): DisplayInstance | null {
slow: value['slow'] === true,
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 {
@@ -1010,6 +1043,17 @@ export class ApiClient {
}
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'),
}
+38
View File
@@ -39,6 +39,15 @@ export interface RealtimeOptions {
instanceId?: string
/** 屏号(Console 从 URL 带下来的 screen),hello 时上报;局域网浏览器没有屏号,留空。 */
screen?: string
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`),管理端「在线大屏」列表的主行。
*
* 空串等同于不传:管理端会退回显示实例 id,老服务端也根本认不出这个字段。
* 高熵值(`getHighEntropyValues`)是异步的,拿到更准的版本后用 setDevice 再补一次。
*/
device?: string
/** 原始 UA,给管理端悬浮提示排障用;空串等同于不传。 */
deviceRaw?: string
/** 当前页名,hello 时上报;翻页后调用 setPage 再补一次。 */
page?: string
/** 大屏从 localStorage 恢复快照后传入,可让服务端只补增量。 */
@@ -93,6 +102,8 @@ export class RealtimeClient {
private readonly displayKey: string | undefined
private readonly instanceId: string | undefined
private readonly screen: string | undefined
private device: string | undefined
private deviceRaw: string | undefined
private readonly reconnect: Required<ReconnectOptions>
private readonly watchdogMs: number
private readonly handshakeMs: number
@@ -128,6 +139,8 @@ export class RealtimeClient {
this.displayKey = options.displayKey
this.instanceId = options.instanceId
this.screen = options.screen
this.device = emptyToUndefined(options.device)
this.deviceRaw = emptyToUndefined(options.deviceRaw)
this.currentPage = options.page
this.currentVersion = options.initialVersion ?? null
this.watchdogMs = options.watchdogMs ?? 25_000
@@ -225,6 +238,21 @@ export class RealtimeClient {
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 {
this.closedByUser = true
this.clearReconnectTimer()
@@ -382,6 +410,9 @@ export class RealtimeClient {
if (this.displayKey !== undefined) hello.displayKey = this.displayKey
if (this.instanceId !== undefined) hello.instanceId = this.instanceId
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
this.send('hello', hello)
}
@@ -474,3 +505,10 @@ export class RealtimeClient {
function toError(error: unknown): 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()
})
// 设备标签(管理端「在线大屏」列表的主行):有就带上,没有就不出现在载荷里。
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)重连也会被同样拒绝,必须停重连并提示登录。
test('close 1008 停止重连并回调 onAuthRequired', async () => {
installFakeWebSocket()
+9
View File
@@ -1093,6 +1093,15 @@ export interface HelloMessage {
* 管理端用它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时留空。
*/
screen?: string
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`),管理端「在线大屏」列表的主行。
*
* 浏览器自己算好报上来:服务端不解析 UA,只做长度截断与清洗;老客户端不带这个字段时
* 服务端行为与今天完全一致(列表退回显示 id)。
*/
device?: string
/** 原始 UA,管理端悬浮提示里给排障用;可为空。 */
deviceRaw?: string
}
export interface AckMessage {