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:
1 parent
ee19b92182
commit
56ed90ff8b
53 files changed
+5653
-199
No files matched your search
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 @@
|
||||
0.5.14
|
||||
0.5.15
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 分钟核对
|
||||
|
||||
@@ -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
@@ -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 里那几行 —— 新版安装器一启动就申请管理员,但只有「为所有用户」那一趟才顺手改防火墙;
|
||||
- 安卓电视一般常亮,无需额外防熄屏;
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
Events []liveEventBody `json:"events"`
|
||||
}
|
||||
|
||||
type liveEventBody struct {
|
||||
Event struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"event"`
|
||||
Reason string `json:"reason"`
|
||||
Groups []struct {
|
||||
Groups []liveGroupBody `json:"groups"`
|
||||
}
|
||||
|
||||
type liveGroupBody struct {
|
||||
EventID int64 `json:"eventId"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
CategoryName string
|
||||
Rows []struct {
|
||||
CategoryName string `json:"categoryName"`
|
||||
Rows []liveRowBody `json:"rows"`
|
||||
}
|
||||
|
||||
type liveRowBody struct {
|
||||
EntryID int64 `json:"entryId"`
|
||||
Rank *int `json:"rank"`
|
||||
ResultText string `json:"resultText"`
|
||||
} `json:"rows"`
|
||||
} `json:"groups"`
|
||||
} `json:"events"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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 载荷;服务端据此识别慢客户端。
|
||||
|
||||
@@ -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,7 +549,9 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
eventID, categoryID int64
|
||||
eventCode, eventName string
|
||||
categoryName string
|
||||
publishedAt string
|
||||
// groupPublishedAt 是这一**组**的发布时间(组内最新一条),来自 SQL 的 group_ts,
|
||||
// 不是这一行自己的时间——组内按名次截断后,第一行的时间不再等于组的最新时间。
|
||||
groupPublishedAt string
|
||||
row dto.EventResultRow
|
||||
rank sql.NullInt64
|
||||
value sql.NullFloat64
|
||||
@@ -471,7 +562,7 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
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{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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)} 分来自上一次按决赛发布):发布时选「只更新名次」再发一次只会更新名次,这些积分与奖牌不会被改动。`
|
||||
}
|
||||
@@ -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' : ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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}`,
|
||||
)
|
||||
})
|
||||
@@ -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">/)
|
||||
})
|
||||
@@ -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(含非安全上下文回退),失败有提示', () => {
|
||||
|
||||
@@ -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\]/)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 起是稳定的):服务端按发布时间给出的并列行
|
||||
* 不许在这里被换位置,否则前两栏的观感就变了。无名次(延迟结算)的行排最后。
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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}$/)
|
||||
})
|
||||
@@ -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
@@ -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'),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user