diff --git a/VERSION b/VERSION index 30a3103..b7efd17 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.15 +dev_0.5.16 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index 8de8215..7f30989 100644 Binary files a/console/installer/art/1x/brand.bmp and b/console/installer/art/1x/brand.bmp differ diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index 5cdb4da..b7d691c 100644 Binary files a/console/installer/art/2x/brand.bmp and b/console/installer/art/2x/brand.bmp differ diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt index c5f3c9c..3afb327 100644 --- a/console/installer/art/version.txt +++ b/console/installer/art/version.txt @@ -1 +1 @@ -0.5.15 +0.5.16 diff --git a/console/package.json b/console/package.json index ca58647..e1b1346 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.15", + "version": "0.5.16", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index 4c596ee..81d0d37 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -67,6 +67,7 @@ - [ ] **局域网大屏能在别的设备打开**:用另一台设备(平板 / 手机)浏览器打开 `http://<主机IP>:19540/display` 能出画面,或直接扫「大屏控制 → 局域网大屏」卡片第一个地址的**二维码**;打不开时先看卡片列出的地址与提示,再按常见问题「大屏控制页显示未检测到局域网地址怎么办?」逐条排查(若卡片地址是 `169.254.x.x`,说明本网段没有 DHCP,同网段的设备照样能连) - [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」 - [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏) +- [ ] **赛事日程侧边栏可用**:到「大屏控制 → 赛事日程」(左列、公告卡下面)加两条日程 → 点「保存」→ 大屏**当前页上**应当叠出侧边栏(默认右侧 22 %)、顶部读数 `已完成 0 / 共 2`,**大屏不换页、轮播不停** → 勾一条「已完成」再保存 → 那条淡化、读数变 `已完成 1 / 共 2` → 打开一次积分明细覆盖层(或推一条公告):侧边栏应**自动收起**,关掉后**自己回来** → 最后**按今天的口径决定总开关**(不公示就关掉,关掉后大屏应恢复原样),并用「每块屏单独设置」把不该显示的那块屏设成「强制关」。⚠️ 这是**低频动作**(每次保存全场大屏全量重拉一次),开赛前配一次即可(见使用手册 9.2 / 17.6) - [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布(发布窗口里按当天口径选「只更新名次」或「按决赛成绩发布」,选完才确认)→ 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布) - [ ] **分项成绩页看得全**:切到大屏「分项成绩」页,**每一栏(含第 3 栏)都从第 1 名开始**;**团体项目的名次列不应是 `-`**(升级后不用重发、不用重算,刷新即可)。⚠️ 例外:只在「团体名次」卡片里按单位录了名次、成绩行上没填的项目仍无大屏名次(见常见问题 Q105 / Q106) - [ ] **报名项批量操作演练**:在「成绩录入」勾选几行试一次**批量退赛 → 批量恢复**(确认框会逐条列出被跳过的 dq / dns 行及原因);「批量移出报名」是**物理删除、不可恢复**,只演示确认框、不要在真实报名上执行(详见使用手册 4.8) diff --git a/docs/使用手册.md b/docs/使用手册.md index d00fdda..b04253a 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -628,6 +628,8 @@ - **保存**:改完模式或项目后点「保存」,提示「实时赛况设置已保存」后大屏按新配置显示;卡片底部注明「当前项目最多 2 个」; - 该卡片的「保存」需要**管理员**身份(见本节开头的权限说明);示例数据模式下不能保存。 +**赛事日程**:左列「公告」卡片下面那张「赛事日程」卡片,把**手填的日程**做成大屏上的一条**竖向进度轴侧边栏**(**可热开关**,叠在大屏**当前页之上**,不影响轮播)。**录入员及以上**就能改。卡片每一项怎么填、大屏上长什么样、什么时候会自动收起,见本节 **9.2**;大屏侧的观感与行为另见 **17.6**。 + **在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `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 · 手机`)。 @@ -686,6 +688,49 @@ 多块副屏都被关掉时,在同一张卡片里逐块点「创建全屏窗口」即可;改用「重新排布」也行,但前提是这些屏都在 Console 的选屏配置里。 +### 9.2 赛事日程(手填日程 + 大屏上的侧边栏) + +**这张卡片是干什么的**:把**手填的赛事日程**做成大屏上一块**侧边栏**,里面是一条**竖向进度轴**。它**与大屏当前在哪一页无关**(轮播中、锁在某一页都照常显示),也**不影响**切页与轮播。**录入员及以上**可改(与大屏公告、逐屏切页同档);未登录 / 服务端未连接时整张卡片禁用并写明原因;示例数据模式下会提示改动未真正保存。 + +**位置**:本页**左列、公告卡之后、「实时赛况」之前**;卡头徽标写 `N 条 · 默认开启 / 关闭`。 + +**表格怎么填**: + +| 列 | 怎么填 | +| --- | --- | +| 时间 | **自由文本**,最长 **20** 字(`08:30`、`上午`、`待定` 都行)。⚠️ **必须填**(不能留空),否则保存时服务端会退回一句「第 N 条日程的时间不能为空」 | +| 标题 | **必填**,最长 **40** 字 | +| 备注 | 可留空,最长 **60** 字(例如 `检录 08:00`);**默认上屏**,可用下面的「备注上屏」整体关掉 | +| 已完成 | 勾上 = 这条已完成(大屏上会**淡化保留**) | +| 操作 | **上移 / 下移 / 删除**——**只有这三个按钮,不能拖拽排序**;第一行的「上移」、最后一行的「下移」是灰的 | + +- 表格下方是「**新增一条**」按钮,右侧写 `共 N / 40 条`(**最多 40 条**,满了按钮不可点);一条都没有时显示「还没有日程条目」; +- **上屏顺序就是你排的顺序**:系统**不解析时间、不比较、也不按时间自动排序**(手填文本没法可靠解析); +- **校验不过时一个请求都不发**:字段级红字贴在该格下方,条数 / 宽度这类整表错误列在表格下方,同时提示「赛事日程校验未通过 / 请先修正标红的字段」。 + +**开关与几何**: + +- **侧边栏默认开启**:总开关。开 = 所有大屏默认显示(除非那块屏被单独设成「强制关」);关 = 所有大屏都不显示; +- **备注上屏**:关掉后大屏上只显示时间与标题; +- **位置**:左侧 / 右侧(默认**右侧**);**宽度**:占整屏的百分比,**15–40 %**(默认 **22 %**)。 + +**每块屏单独设置**(覆盖上面的总开关):卡片下半部分**一行一块屏**,显示名按 **手动别名 → 自动设备标签 → 实例 id**(怎么改名见本节前面「在线大屏」那段),右边一个三态下拉: + +| 选项 | 含义 | +| --- | --- | +| 跟随总开关(默认) | 由上面的总开关决定 | +| 强制开 | 这块屏**无论总开关怎么设都显示** | +| 强制关 | 这块屏**不显示** | + +- 切回「**跟随总开关**」会**真的把那条覆盖删掉**(不会留一个没用的空键); +- 列表里**列全部在线实例**,另外把「离线、但身上挂着强制开 / 强制关」的也列出来(否则那块平板今天没开,你就再也撤不掉它的强制关了);**还没有任何大屏上报**时这里显示「还没有大屏上报,暂时没有可单独设置的设备;新设备接上后回到这里即可」;列表里已经找不到的覆盖条数会另写一句说明——那些设置**照原样保存**,不会被悄悄删掉。 + +**保存**:点「保存」= **整份替换**并**立刻推给所有大屏**(这就是「热开关」:不用刷新、不用重启)。成功提示形如「已把赛事日程改成「共 2 条 · 默认开启 · 右侧 22%」」,并注明「大屏立刻生效;配置随赛事库保存,刷新与重启都不会丢」;失败会把**服务端原文**显示出来(条数超限 / 字段过长 / 权限不足)。 + +⚠️ **这是低频动作**:每次保存都会让**全场大屏做一次全量重拉**(局域网内几百毫秒,现场基本无感)——所以它适合「**开赛前配一次 + 现场偶尔勾一下「已完成」**」,**不要**拿它做高频刷新。 + +**配置存在哪**:赛事库的设置里(键名 `display.schedule`,与大屏公告同一套机制)——**不新建表、不改数据库结构**,**随赛事包一起导出 / 导入**;刷新、重启服务端、切换赛事项目都不会丢。 + --- ## 10. 基础数据 @@ -1274,6 +1319,28 @@ - **kiosk 全屏窗口**(多屏时每屏一个)不显示任何调试标识,正式上屏的画面是干净的;多屏 kiosk 也不响应 F11。 - 单屏窗口化窗口按 **F11** 全屏后,自绘标题栏与「实例 · 屏」会一起消失、画面铺满整块屏;再按一次 F11 回到窗口化,标题栏与标识一起回来。 +### 17.6 赛事日程侧边栏 + +管理端在「大屏控制 → 赛事日程」里配了**手填日程**并打开开关后,**每一块大屏**上都会多出一块**侧边栏**(默认在**右侧**、占屏 **22 %**,左右与宽度可在管理端改)。它**叠在当前页之上**:**不改当前页、不停轮播**——大屏本来在轮播就继续轮,锁着就继续锁着,侧边栏只是多出来一层(见使用手册 9.2)。 + +- **一条竖向进度轴 + 三态节点**:**已完成**(实心节点 + 整条淡化保留)、**当前**(节点放大带光晕,最醒目)、**未开始**(低对比);面板顶部一行读数 **`已完成 x / 共 y`**; +- **「当前项」= 第一个没勾「已完成」的条目**——**不按时间判断**(时间是完全手填的自由文本,`上午` / `待定` 这种没法可靠解析); +- **备注默认上屏**(例如每条的「检录 08:00」),管理端可以整体关掉,关掉后只显示时间与标题; +- **日期/时间只是照抄你填的字**:系统**不解析、不比较、不按时间排序**,**上屏顺序 = 管理端里的顺序**。 + +> ⚠️ **它不是「完整日程公示」**:条目放不下时,面板会**围绕当前项开窗**——当前项固定在显眼位置、前后各显示若干条,**已完成的与很靠后的条目会滚出视图**(被截住的方向上会写一句「上方 / 下方还有 N 项」)。要把**完整**日程公示出来,请用轮播页或另行打印公示,**别指望这条侧边栏**。 + +**排版**:字号按文本长度与可用高度**自适应**,但有**硬上下限**——**宁可少显示几行,也不缩到看不清**;长标题最多 3 行、长备注最多 2 行,超出的部分按显示宽度**省略**;时间与备注比标题小一档(0.8 倍,同样有下限)。**1080p 与 4K 都按屏幕等比缩放**(4K 上字与面板一起放大,不会多放大一次)。 + +**外观**:**深色半透明 + 毛玻璃**;**不支持毛玻璃的老浏览器会退化成更不透明的深底**(保证字看得清),功能不缺。系统开了「减少动态效果」(`prefers-reduced-motion`)时不做入场动效。 + +**什么时候它会自己收起来**(**开关状态不动**,事情过去自动回来): + +- 管理端推送的**积分明细覆盖层**开着期间(见 17.3); +- **全屏公告**正在播期间(公告退场就回来)。 + +**关掉之后**:管理端把总开关一关,大屏上**连一个元素都不会留下**,画面与**没有这个功能时逐字节一致**;某一块屏单独设成「强制关」时也一样(只影响那一块屏)。 + --- ## 18. 常见操作问题 diff --git a/docs/升级说明.md b/docs/升级说明.md index 26ecf32..bc2d8b6 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,6 +6,97 @@ --- +## dev_0.5.16 + +### 一句话 + +**没有任何数据迁移:不新建表、不写迁移,已有数据一行不改**,成绩、名次、积分、榜单与**所有导出件一个字不变**,不用重配、不用重新导入规则包。这一版**新增一个可以热开关的大屏功能**——**赛事日程侧边栏**:把手填的赛事日程做成**一条竖向进度轴**,叠在**大屏当前页之上**(那块屏此刻在哪一页都不影响),管理端「大屏控制」页也多了一张「赛事日程」卡片,**录入员**就能改。 + +### ① 新:大屏上的「赛事日程侧边栏」 + +**它是什么**:一块**深色半透明(毛玻璃)面板**,贴在屏幕**右侧**(可改成左侧)、**默认占屏 22%**(可设 **15–40%**)。面板左侧一条**竖向时间轴**,一条日程一个节点,节点**三态**: + +| 状态 | 判据 | 长什么样 | +| --- | --- | --- | +| 已完成 | 勾了「已完成」 | 实心节点 + 整条**淡化保留**(一眼看出走过多远) | +| 当前 | **第一个没勾「已完成」的条目** | 节点**放大 + 光晕**(最醒目) | +| 未开始 | 其余 | 低对比 | + +面板顶部一行读数 **`已完成 x / 共 y`**;**备注默认上屏**(可在卡片里整体关掉)。 + +**三条必须交代清楚的现场口径**: + +1. **它叠在当前页之上,不改当前页、不停轮播**——大屏正在轮播、或锁在某一页,都照旧跑;侧边栏只是多一层; +2. **「当前项」看的是勾选、不是时间**:`时间` 那一栏是**完全手填的自由文本**(`08:30` / `上午` / `待定` 都行),系统**不解析、不比较、不按时间排序**——**上屏顺序就是你在卡片里排的顺序**; +3. ⚠️ **它不是「完整日程公示」**:条目放不下时面板会**围绕当前项开窗**(当前项固定在显眼位置、前后各显示若干条),**已完成的与很靠后的条目会滚出视图**,被截住的方向上会写一句「上方 / 下方还有 N 项」。要公示**完整**日程,用轮播页或另行打印,**别指望这条侧边栏**。 + +**排版**:字号按**文本长度与可用高度自适应**,但有**硬上下限**——**宁可少显示几行,也不把字缩到看不清**;长标题最多 **3 行**、长备注最多 **2 行**,超出按**显示宽度**省略(中文与字母按各自宽度算,不按字符个数切);时间与备注比标题小一档(**0.8 倍**,同样有下限)。**1080p 与 4K 都按屏幕等比缩放**(4K 上字与面板一起放大,不会多放大一次)。 + +**老浏览器**:面板用 `backdrop-filter` 毛玻璃;**不支持毛玻璃的浏览器会退化成更不透明的深底**(保证白字看得清),功能不缺。 + +**什么情况下它会自动收起**(开关状态一个字节不动,事情过去自动回来):管理端推送的**积分明细覆盖层**开着期间;**全屏公告**正在播期间。 + +⚠️ **关着的时候**(总开关关掉,或这份配置从来没配过):侧边栏**连一个元素都不留在页面上**,大屏的画面、轮播与其它一切,与**没有这个功能时逐字节一致**。 + +### ② 权限、存哪、以及一条代价 + +- **谁能改**:**录入员及以上**(与大屏公告、逐屏切页同一档;未登录 401、只读访客不能改)。为什么不走「赛事设置」那条路:`PUT /api/settings` 是**管理员专有**,而现场要求**录入员也能改**——所以走的是**同一条大屏指令**(`POST /api/display/command` 的 `schedule` 动作,与大屏公告同一档),改动**记审计**; +- **存哪**:赛事库的**一个设置键**(一份 JSON)——**不新建表、不写迁移**,而且**随赛事包一起走**(导出赛事包自然带上、换机器一致),**刷新 / 重启 / 切赛事都不丢**; +- **保存 = 整份替换**,并**立刻推给所有大屏**(这就是「热开关」:改完就生效,不用刷新、不用重启); +- ⚠️ **这是低频动作**:**每次保存都会让全场大屏做一次全量重拉**(局域网内几百毫秒,现场基本无感)。它的用途就是**开赛前配一次 + 现场偶尔勾一下「已完成」**,**不要**拿它当高频刷新用(例如每几秒保存一次)。 + +### ③ 管理端「大屏控制 → 赛事日程」卡片 + +位置在**左列、公告卡之后、「实时赛况」之前**,卡头徽标写 `N 条 · 默认开启 / 关闭`。 + +| 这块 | 怎么用 | +| --- | --- | +| 条目表格 | **时间**(自由填,最长 20 字)|**标题**(必填,40 字)|**备注**(可留空,60 字)|**已完成**(开关)|**上移 / 下移 / 删除**(首行上移、末行下移是灰的) | +| 新增 | 表格下方「新增一条」,右侧写 `共 N / 40 条`;一条都没有时显示「还没有日程条目」 | +| 开关组 | **侧边栏默认开启**(总开关,所有大屏的默认值)、**备注上屏**(关掉就只显示时间与标题) | +| 几何 | **位置**(左侧 / 右侧)+ **宽度**(15–40 %) | +| 每块屏单独设置 | 一行一块屏(显示名 = 手动别名 → 自动设备标签 → 实例 id),三态下拉 **跟随总开关 / 强制开 / 强制关**;切回「跟随总开关」会**真的把那条覆盖删掉**(不留垃圾键) | +| 保存 | 整份替换 + 立刻生效;成功有提示,失败把**服务端原文**显示出来 | + +- **校验不过时一个请求都不发**:字段级红字贴在该格下方,条数与宽度这类整表错误列在表格下方,同时提示「赛事日程校验未通过 / 请先修正标红的字段」; +- ⚠️ **时间也要填**(可以写「待定」这种字,但不能留空):留空时服务端会退回一句「第 N 条日程的时间不能为空」,这一次保存不生效; +- **只读身份**(未登录 / 服务端未连接)时**整张卡片禁用**并写明原因;**示例数据模式**下会明确提示改动未真正保存; +- 排序**只有上移 / 下移两个按钮、没有拖拽**(有意的),**最多 40 条**。 + +### ④ ⚠️ 一条边界:**导出的名次与屏幕上可能不一样**(本版**故意没改**) + +这一条**不是本版引入的**,而是一条**既有口径**(有测试钉着);本版按「兼容性最大化、不动已交付的东西」**原样保留**: + +| 看哪 | 名次取谁 | +| --- | --- | +| **屏幕**(大屏的分项成绩 / 实时赛况 / 底栏) | **成绩行上的名次优先**,为空时回落「通道名次」 | +| **导出件**(结果导出的分项成绩表) | **「通道名次」优先**,为空时回落成绩行上的名次 | + +**什么时候会遇到**:只有一种序列——**先「按决赛成绩发布」**(会重建通道名次),**之后又只发一次「只更新名次」**(只写成绩行上的名次、**不重建通道名次**)。这时**导出件显示的是上一次计分的名次、屏幕显示的是新名次**。**正常赛程遇不到**:预赛只排名次 → 决赛才计分,两边名次是同一个值。 + +**遇到了怎么办**:对该小项 + 组别再**按「按决赛成绩发布」发布一次**即可——通道名次会按当前成绩重建,两边立刻一致(不用改库、不用重导)。 + +### ⑤ 升级后 5 分钟核对 + +1. 到「大屏控制 → 赛事日程」(左列、公告卡下面):「新增一条」加两条日程(例如 `08:30` / `男子 100 米预赛` / 备注 `检录 08:00`),点「保存」; +2. 看大屏:**当前那一页上应当叠出一块右侧面板**,顶部写 `已完成 0 / 共 2`、第一条带光晕;**大屏没有换页,轮播也没有停**(本来在轮的继续轮); +3. 回卡片勾上第一条的**「已完成」**再保存:大屏上这条**淡化**、第二条变成带光晕的「当前」,读数变 `已完成 1 / 共 2`; +4. 在「积分榜」页点某单位的「推送到大屏」(或推一条公告):**明细覆盖层 / 全屏公告期间侧边栏应当自动收起**,关掉明细 / 公告退场后**原样回来**(卡片上的开关一个字都不用动); +5. 把卡片里的**总开关关掉**再保存:大屏应当**恢复成没有这块侧边栏的样子**(与没开过这个功能时一致); +6. 其余照旧:**不需要数据迁移、不需要重新导入规则包、不需要重配任何东西**;已有成绩、榜单与导出件**一个字不变**。 + +### ⑥ 详细说明在哪 + +| 想了解 | 看 | +| --- | --- | +| 卡片每一项怎么填、侧边栏长什么样 | 《使用手册》9.2(赛事日程卡片)、17.6(大屏上的侧边栏) | +| 为什么看不到全部日程 /「当前项」为什么按勾选 / 字会不会太小 | 《常见问题》Q109 | +| 打开明细或推公告时它不见了 | 《常见问题》Q110 | +| 导出件与屏幕的名次为什么不一样 | 本节 ④、《常见问题》Q111 | +| 谁能改、「每次保存都会让全场重拉」 | 本节 ②、《使用手册》9.2 | + +--- + ## dev_0.5.15 ### 一句话 diff --git a/docs/常见问题.md b/docs/常见问题.md index 8289c84..f42621d 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -801,3 +801,52 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 - **以前给某台设备切页后,列表底部会多出一个该设备的离线条目**(根因是翻页会补发一次握手,而浏览器没有稳定 id、服务端每次都新开一条实例),**现在已经修好**:浏览器本机记住自己的实例 id,服务端也做了兜底(同一条连接重复握手复用原实例),升级后不会再出现这种重复条目。 顺带一句:「局域网大屏」卡片里**每一条地址**都能出二维码了——第一条是常驻二维码,其余每条点「**二维码**」按钮临时出码(再点一次 / 按 Esc / 点空白处收起,同一时刻只留一个),不用为别的网段再手敲地址。详见《使用手册》第 9 节、《部署指南》7.5。 + +**Q108:大屏上那条「赛事日程」侧边栏怎么开?为什么有一块屏没有?** + +到管理端「**大屏控制**」页**左列、公告卡下面**那张「**赛事日程**」卡片(**录入员及以上**就能改,与大屏公告同档): + +- **先加日程**:点「新增一条」,填**时间**(自由文本,`08:30` / `上午` / `待定` 都行,最长 20 字)、**标题**(必填,最长 40 字)、**备注**(可留空,最长 60 字),最多 **40 条**;顺序用**上移 / 下移**调(只有这两个按钮,**不能拖拽**); +- **再开开关**:把「**侧边栏默认开启**」打开(开 = 所有大屏默认显示;关 = 都不显示),选**位置**(默认右侧)与**宽度**(15–40 %,默认 22 %),点「**保存**」; +- **保存后就生效,不用刷新大屏**:侧边栏是**叠在当前页之上**的,不改当前页、也不停轮播——所以那块屏本来在轮播就继续轮,锁着就继续锁着。 + +**某一块屏没有,多半是这三件事之一**: + +1. 卡片下半部分「**每块屏单独设置**」里,那一块屏被设成了「**强制关**」——下拉切回「跟随总开关」再保存即可(切回「跟随总开关」会把那条设置**真的删掉**); +2. 那块屏**当时没连上**(大屏窗口被关掉过、或网络断过)——它重连 / 刷新后就会带上侧边栏(**配置存在赛事库里**,刷新、重启服务端、切赛事都不丢);还看不到就先点一次管理端的「强制刷新」让它重拉快照; +3. 「侧边栏默认开启」本来就是关的,而那块屏没有单独的「强制开」。 + +⚠️ 另外两个常见误会:**「当前项」看的是勾选、不是时间**——它就是**第一个没勾「已完成」的条目**;**顺序就是你排的顺序**,系统**不按时间自动排序**(手填的时间是自由文本,`上午` / `待定` 没法可靠解析,所以**不做、也不会做**)。详见《使用手册》9.2 / 17.6。 + +**Q109:侧边栏上为什么看不到全部日程?字会不会太小或太大?** + +**先说结论:它不是「完整日程公示」,是「围绕现在进行到哪一项」的现场提示。** + +- 条目**放不下**时,面板会**围绕当前项开窗**:当前项固定在显眼位置、前后各显示若干条,**已完成的与很靠后的条目会滚出视图**(被截住的方向上会写一句「上方 / 下方还有 N 项」)。要**完整公示**日程,请用轮播页或另行打印,别指望这条侧边栏; +- 想让它一次显示更多条:把**宽度**调大一点(最大 40 %),或**精简标题 / 备注**(长文本会占掉行数)——**最多 40 条**是硬上限,但放不下时**绝不会**把字缩到看不清。 + +**字号**:按文本长度与可用高度**自适应**,且有**硬上下限**——**宁可少显示几行,也不缩到看不清**;长标题最多 3 行、长备注最多 2 行,超出**省略**(按显示宽度算,中文与字母各自计宽);时间与备注比标题小一档(0.8 倍,同样有下限)。**1080p 与 4K 都按屏幕等比缩放**:4K 上字与面板**一起**放大,不会出现「4K 上字特别小」或「字特别大」。 + +**Q110:打开积分明细或推公告的时候,侧边栏不见了,是坏了吗?** + +没坏,这是**故意的**: + +- **明细覆盖层**开着期间(「推送到大屏」那块半屏明细); +- **全屏公告**正在播期间。 + +这两种情况下侧边栏会**自动收起**,**开关状态一个字节都不动**——明细关掉 / 公告退场后**它自己就回来了**,不用重新保存、也不用重新开开关。为什么要这样:这两件事都是「临时要在整块屏上讲另一件事」,被侧边栏压住会看不全。 + +**Q111:导出件上的名次和大屏上的名次为什么不一样?** + +**先说结论:这是一条既有口径**(屏幕上和导出件上「名次取谁」的优先级**故意不同**),**正常赛程遇不到**;真遇到了,**按决赛发布一次**就能让两边一致。 + +| 看哪 | 名次取谁 | +| --- | --- | +| **屏幕**(大屏的分项成绩 / 实时赛况 / 底栏「最新完成项目」) | **成绩行上的名次优先**,为空时回落「通道名次」 | +| **导出件**(「导出 → 结果」的分项成绩表) | **「通道名次」优先**,为空时回落成绩行上的名次 | + +**什么时候两边会分叉**:只有一种序列——**先「按决赛成绩发布」**(会重建通道名次),**之后又只发了一次「只更新名次」**(只写成绩行上的名次、**不重建通道名次**)。这时**导出件显示的是上一次计分的名次,屏幕显示的是新名次**。 + +**为什么这么设计**:屏幕侧必须「成绩行优先」——因为「只更新名次」的发布**只写成绩行上的名次、不重建通道名次**,一旦让通道名次优先,个人项目在那种发布之后会**显示上一次的旧名次**(见 Q105 与《升级说明》`dev_0.5.15` 的 ①/④)。导出侧则是**既有口径、有测试钉着**,本版按「兼容性最大化、不动已交付的东西」**原样保留**(见《升级说明》`dev_0.5.16` 的 ④)。 + +**遇到了怎么办**:对该小项 + 组别**再按「按决赛成绩发布」发布一次**即可(通道名次会按当前成绩重建,两边立刻一致),**不用改库、不用重导、也不用重算整场**。正常赛程(预赛只排名次 → 决赛才计分)两边名次本来就是同一个值,不会遇到。 diff --git a/docs/部署指南.md b/docs/部署指南.md index 1ea805b..e94b3bb 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -751,6 +751,7 @@ http://<主机IP>:19540/display - [ ] **单位 logo 已上传且在榜上显示**(总积分榜 / 单位奖牌榜 / 分项成绩表 / 个人积分榜,见 7.7) - [ ] **大屏字体已选好并核对**:「系统设置 → 赛事信息 → 字体」里选了标题字体与正文字体,大屏上显示正常;上传的字体在数据目录 `event_projs\<项目名>\uploads\fonts\` 里,导出赛事项目包会一并带走(见 8.5) - [ ] **公告可用**:推送一条测试公告,全屏到时后底栏仍能看到正文,点「清除公告」后底栏回到「暂无公告」(见 7.6) +- [ ] **赛事日程侧边栏(本版新增,用不用都过一遍开关)**:到「大屏控制 → 赛事日程」(左列、公告卡下面)加两条日程 → 保存 → 大屏**当前页上**叠出侧边栏(默认右侧 22 %)、读数 `已完成 0 / 共 2`,**大屏不换页、轮播不停**;勾一条「已完成」再保存,着色与读数跟着变;打开积分明细或推一条公告时侧边栏**自动收起**、关掉后**自己回来**;**不用就在开赛前把总开关关掉**(关掉后大屏与没有这个功能时一致)。⚠️ 每次保存都会让全场大屏全量重拉一次,是**低频动作**(见《使用手册》9.2 / 17.6) - [ ] 管理端各角色账号可登录,权限正确 - [ ] 主机 IP 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台 - [ ] 防熄屏、屏保、休眠、系统通知已关闭 diff --git a/server/internal/dto/settings.go b/server/internal/dto/settings.go index f7584f1..0941b84 100644 --- a/server/internal/dto/settings.go +++ b/server/internal/dto/settings.go @@ -5,8 +5,10 @@ package dto import ( + "encoding/json" "strconv" "strings" + "unicode/utf8" ) // EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构, @@ -48,6 +50,208 @@ type EventSettingsDisplay struct { // LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走 // 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。 LiveSource string `json:"liveSource"` + // Schedule 是赛事日程侧边栏(手填的日程 + 开关 + 左右与宽度 + 单屏覆盖)。 + // 库里以 display.schedule 存一份 JSON,由大屏指令写入;读不出来(缺键 / 坏 JSON)时是 null, + // 大屏按「没有这个功能」处理 —— 一条脏设置绝不能让大屏整块不显示。 + Schedule *ScheduleConfig `json:"schedule"` +} + +// ---------- 赛事日程侧边栏 ---------- + +// SettingKeySchedule 是整份日程侧边栏配置在 settings 表里的键,值是一份 JSON。 +// +// 与 display.announce_text 同一套机制:由 POST /api/display/command 的 schedule 指令写入 +// (PUT /api/settings 是 admin-only,而现场要求录入员也能切),随 snapshot.settings 下发大屏。 +// 不新建表、不写迁移。 +const SettingKeySchedule = "display.schedule" + +// 日程的取值上限。长度一律按**字符**(rune)算:中文标题按字节算会被切出半个字。 +const ( + // MaxScheduleItems 是日程条目数上限:一场赛事的日程排不下更多,也挡住「整屏全是条目」这种 + // 把大屏刷爆的输入。 + MaxScheduleItems = 40 + // MaxScheduleTimeChars / TitleChars / NoteChars 是单项三个文本字段的字符数上限。 + MaxScheduleTimeChars = 20 + MaxScheduleTitleChars = 40 + MaxScheduleNoteChars = 60 + // MaxScheduleOverrides 是单屏覆盖的条数上限(键是实例 id)。 + MaxScheduleOverrides = 50 + // MaxScheduleOverrideKeyChars 是实例 id 的字符数上限,与设备自报标签同一量级。 + MaxScheduleOverrideKeyChars = 120 + // 侧边栏宽度按「占屏百分比」存,夹在这个区间:更窄放不下字,更宽会盖住大屏正文。 + MinScheduleWidthPercent = 15 + MaxScheduleWidthPercent = 40 + DefaultScheduleWidthPercent = 22 +) + +// 侧边栏贴哪一边。认不出的取值一律回落 ScheduleSideRight(与默认口径一致)。 +const ( + ScheduleSideLeft = "left" + ScheduleSideRight = "right" +) + +// 单屏覆盖的三态:跟随总开关 / 强制开 / 强制关。 +const ( + ScheduleOverrideInherit = "inherit" + ScheduleOverrideOn = "on" + ScheduleOverrideOff = "off" +) + +// ScheduleItem 是日程里的一条。**手填**,与小项、报名无关,不参与算分。 +// +// Time 是自由文本(「08:30」「上午」「待定」都行),**允许为空**(先排标题、时间回头补): +// 服务端不解析、不比较、不排序,列表顺序就是排定的顺序(「当前项」= 第一个 done=false 的项, +// 由大屏算)。空的只是不上屏显示时间那一格。管理端 scheduleEditor.ts 的 validateSchedule +// 同一口径:时间只卡长度,标题才必填。 +// note / done 是可缺字段,缺省即「没有备注」「未完成」。 +type ScheduleItem struct { + Time string `json:"time"` + Title string `json:"title"` + Note string `json:"note,omitempty"` + Done bool `json:"done,omitempty"` +} + +// ScheduleConfig 是整份日程侧边栏配置。保存是**整份替换**,服务端不做局部合并 +// (客户端手上就有整份配置,合并只会让两边不一致)。 +// +// InstanceOverrides 按**实例 id**(GET /api/display/instances 的 id)覆盖总开关; +// 缺省即「全部 inherit」,且 `inherit` 不进 map(它就是「没有这条设置」,见下面两处解析)。 +// 字段名与 web/src/shared/types.ts 的 ScheduleSidebarConfig 逐字对应。 +type ScheduleConfig struct { + Items []ScheduleItem `json:"items"` + Visible bool `json:"visible"` + Side string `json:"side"` + WidthPercent float64 `json:"widthPercent"` + // ShowNote 缺省 = **true**(备注默认上屏,与管理端表单、大屏解析两边一致)。 + // 缺键时不能吃 Go 的零值 false,收口在 ScheduleShowNote。 + ShowNote bool `json:"showNote"` + InstanceOverrides map[string]string `json:"instanceOverrides,omitempty"` +} + +// ScheduleShowNote 从原始 JSON 对象里读 showNote,缺省回落 **true**(备注默认上屏)。 +// +// 为什么单独解一次而不是直接吃 ScheduleConfig 的 bool:**bool 的零值分不出「键不存在」与 +// 「显式 false」**,而这两者的语义完全不同 —— 库里的 JSON 只要没带这个键(别的客户端写的、 +// 手工改库、老数据),零值就会让服务端下发 false,备注在没有任何人关过它的前提下莫名不上屏。 +// 管理端默认 true、大屏解析 `showNote !== false`(也是 true),服务端必须同口径。 +// +// 值为 `null` 或不是布尔时同样回落 true(与前端两边的「不是 false 就当开」一致)。 +// 解成 `*bool` 而不是 `bool` 正是为了这个:Go 把 JSON `null` 解进 `bool` 是**静默不动**、 +// 也不报错,用值类型接就会把它当 false。 +func ScheduleShowNote(raw []byte) bool { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return true + } + value, ok := obj["showNote"] + if !ok { + return true + } + var b *bool + if err := json.Unmarshal(value, &b); err != nil || b == nil { + return true + } + return *b +} + +// NormalizeScheduleSide 归一左右取值:只认 left / right,其它(含空串)回落 right。 +func NormalizeScheduleSide(raw string) string { + if strings.ToLower(strings.TrimSpace(raw)) == ScheduleSideLeft { + return ScheduleSideLeft + } + return ScheduleSideRight +} + +// NormalizeScheduleWidth 把宽度夹到 [MinScheduleWidthPercent, MaxScheduleWidthPercent]; +// 缺省(非正数)回落 DefaultScheduleWidthPercent。 +// +// 夹取而不是报错:宽度越界多半是前端算错了一个数,为它让整份日程存不下去不划算, +// 而且夹到一个「能看」的值没有信息损失。 +func NormalizeScheduleWidth(percent float64) float64 { + switch { + case percent <= 0: + return DefaultScheduleWidthPercent + case percent < MinScheduleWidthPercent: + return MinScheduleWidthPercent + case percent > MaxScheduleWidthPercent: + return MaxScheduleWidthPercent + } + return percent +} + +// ParseScheduleValue 解析 settings 里的 display.schedule(**读入口,永不报错**)。 +// +// 这个键可能被手工改过、被别的版本写过、或者写到一半断电,所以口径与 record_bonus.levels、 +// ParseLiveEventIDs 一致:坏 JSON 整个当「没有这个功能」(返回 nil),结构上不成立的那几条 +// (**标题**缺失的条目、非三态的覆盖、认不出的左右)丢掉,条数取前 MaxScheduleItems / +// MaxScheduleOverrides 条。**一条脏设置绝不能让大屏整块不显示**。 +// +// 与写入口(httpapi 的 scheduleFromPayload)刻意不对称:那边越界报 400 而不是静默截断, +// 因为那是人在操作、要立刻知道哪一项不合规;这边面对的是已经躺在库里的数据,报错没有出口。 +// +// 两处「缺省不等于零值」的收口都在这里:showNote 缺键当 **true**(见 ScheduleShowNote), +// `inherit` 的覆盖当「没有这条设置」丢掉(前端两边解析也是这么做的)。 +// +// 文本只去首尾空白、不剥控制字符:写入口已经清洗过,老数据里万一还留着换行, +// 前端按普通空白渲染,不足以炸大屏;而读入口反向改写内容会掩盖数据被谁改坏过。 +func ParseScheduleValue(raw string) *ScheduleConfig { + if strings.TrimSpace(raw) == "" { + return nil + } + var cfg ScheduleConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return nil + } + + items := make([]ScheduleItem, 0, len(cfg.Items)) + for _, it := range cfg.Items { + it.Time = strings.TrimSpace(it.Time) + it.Title = strings.TrimSpace(it.Title) + it.Note = strings.TrimSpace(it.Note) + // 只认标题必填:标题是列表主行,缺了就是一条空白线,整条丢掉。**时间可以为空** + // (写入口允许,管理端也允许),空串照样保留这条,大屏只是不画时间那一格。 + if it.Title == "" { + continue + } + items = append(items, it) + if len(items) == MaxScheduleItems { + break + } + } + + var overrides map[string]string + if len(cfg.InstanceOverrides) > 0 { + overrides = make(map[string]string, len(cfg.InstanceOverrides)) + for id, value := range cfg.InstanceOverrides { + id = strings.TrimSpace(id) + if id == "" || utf8.RuneCountInString(id) > MaxScheduleOverrideKeyChars { + continue + } + // inherit 是「没有这条设置」,缺省就是它:存着只会白占 MaxScheduleOverrides + // 的条数预算(管理端与大屏解析都会把这种条目丢掉,两边不会照原样再写回来)。 + switch value { + case ScheduleOverrideOn, ScheduleOverrideOff: + default: + continue + } + overrides[id] = value + if len(overrides) == MaxScheduleOverrides { + break + } + } + if len(overrides) == 0 { + overrides = nil + } + } + + return &ScheduleConfig{ + Items: items, + Visible: cfg.Visible, + Side: NormalizeScheduleSide(cfg.Side), + WidthPercent: NormalizeScheduleWidth(cfg.WidthPercent), + ShowNote: ScheduleShowNote([]byte(raw)), + InstanceOverrides: overrides, + } } // 「大屏实时赛况」的两个设置键与取值约束。 @@ -204,6 +408,8 @@ func ParseEventSettings(values map[string]string) EventSettings { // 实时赛况的「当前项目」:人工指定 + 判定方式,读取侧(大屏快照与管理端)口径一致。 out.Display.LiveEvents = ParseLiveEventIDs(values[SettingKeyLiveEvents]) out.Display.LiveSource = NormalizeLiveSource(values[SettingKeyLiveSource]) + // 赛事日程侧边栏:由大屏指令写入(recorder 也能推),缺键 / 坏 JSON 一律 null。 + out.Display.Schedule = ParseScheduleValue(values[SettingKeySchedule]) out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1) out.Standings.RankingMode = orDefault(values["standings.mode"], "points") diff --git a/server/internal/dto/settings_test.go b/server/internal/dto/settings_test.go index ba1f098..17111d1 100644 --- a/server/internal/dto/settings_test.go +++ b/server/internal/dto/settings_test.go @@ -3,6 +3,7 @@ package dto import ( "encoding/json" "strconv" + "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" @@ -289,3 +290,198 @@ func TestTeamAndEventFromDomainNullableFields(t *testing.T) { t.Fatalf("sortOrder 应保留 0: %s", eventRaw) } } + +// ---------- 赛事日程侧边栏 ---------- + +// 日程存在 display.schedule 里,随 ParseEventSettings 一起下发; +// 缺键(老赛事 / 从没配过)必须是 null,前端按「没有这个功能」处理。 +func TestParseEventSettingsSchedule(t *testing.T) { + out := ParseEventSettings(map[string]string{ + SettingKeySchedule: `{"items":[{"time":"08:30","title":"开幕式","done":true}],` + + `"visible":true,"side":"left","widthPercent":22,"showNote":true}`, + }) + if out.Display.Schedule == nil { + t.Fatal("配置了日程就应下发") + } + if !out.Display.Schedule.Visible || out.Display.Schedule.Side != ScheduleSideLeft { + t.Fatalf("日程配置应原样解析: %+v", out.Display.Schedule) + } + if len(out.Display.Schedule.Items) != 1 || out.Display.Schedule.Items[0].Title != "开幕式" { + t.Fatalf("条目应原样解析: %+v", out.Display.Schedule.Items) + } + + // 时间留空的条目照样下发(只是那一条不显示时间格)。 + withEmptyTime := ParseEventSettings(map[string]string{ + SettingKeySchedule: `{"items":[{"time":"","title":"时间回头补"}],"visible":true}`, + }) + if items := withEmptyTime.Display.Schedule.Items; len(items) != 1 || items[0].Time != "" { + t.Fatalf("时间为空的条目应照样下发: %+v", items) + } + // 这个 payload 没带 showNote:缺省的语义是 **true**(备注默认上屏),不是 Go 的零值 false。 + if !withEmptyTime.Display.Schedule.ShowNote { + t.Fatal("缺 showNote 键时应缺省为 true(备注默认上屏)") + } + + if got := ParseEventSettings(nil).Display.Schedule; got != nil { + t.Fatalf("没配过日程时应为 null,得到 %+v", got) + } +} + +// showNote 的缺省 = **true**:bool 的零值分不出「键不存在」与「显式 false」,而两者语义相反 +// ——库里那份 JSON 没带这个键(别的客户端写的 / 手工改库 / 老数据)时,服务端下发 false 会 +// 让备注在没人关过它的前提下莫名不上屏。管理端默认 true、大屏解析 `!== false` 也是 true。 +func TestParseScheduleValueShowNoteDefault(t *testing.T) { + for _, tc := range []struct { + name string + raw string + want bool + }{ + {"缺键", `{"items":[]}`, true}, + {"显式 true", `{"items":[],"showNote":true}`, true}, + {"显式 false", `{"items":[],"showNote":false}`, false}, + {"值为 null", `{"items":[],"showNote":null}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := ParseScheduleValue(tc.raw) + if cfg == nil { + t.Fatalf("%s 应能解析", tc.raw) + } + if cfg.ShowNote != tc.want { + t.Fatalf("%s 的 showNote 应为 %v,得到 %v", tc.raw, tc.want, cfg.ShowNote) + } + }) + } + + // 序列化出去也必须是显式布尔(前端按布尔读,不给 undefined)。 + raw, err := json.Marshal(ParseScheduleValue(`{"items":[]}`)) + if err != nil { + t.Fatalf("序列化: %v", err) + } + if !strings.Contains(string(raw), `"showNote":true`) { + t.Fatalf("缺键解析出的配置序列化时 showNote 应为 true: %s", raw) + } + + // showNote 是字符串这种**类型**损坏仍走既有口径「坏 JSON 整个当没有这个功能」: + // 整份解析返回 nil(不是静默当开)。类型不对的字段与别的字段一样会解不动结构体。 + if got := ParseScheduleValue(`{"items":[],"showNote":"no"}`); got != nil { + t.Fatalf("类型不对的字段当空,得到 %+v", got) + } +} + +// 读入口**永不报错**:坏 JSON / 被手工改坏的值一律当空,绝不能让大屏整块不显示。 +// 口径与 record_bonus.levels(rules/settings.go)、ParseLiveEventIDs 一致。 +func TestParseScheduleValueTolerant(t *testing.T) { + for _, raw := range []string{ + "", + " ", + "{items: [", + `"日程"`, + `{"items":[{"time":"08:30","title":"开幕`, + `[1,2,3]`, + } { + if got := ParseScheduleValue(raw); got != nil { + t.Fatalf("坏数据 %q 应当空,得到 %+v", raw, got) + } + } + + // 结构上不成立的条目 / 覆盖丢掉,认不出的左右与越界宽度按读侧口径归一。 + // 时间为空的条目**不丢**:写入口与管理端都允许时间留空(先排标题、回头补时间), + // 读侧把它丢掉等于静默吞掉一条操作员亲手存下来的日程。 + cfg := ParseScheduleValue(`{"items":[{"time":"08:30","title":"开幕式"},{"time":"","title":"时间待定"},` + + `{"time":"09:00","title":" "},{"time":"上午","title":"趣味赛","note":"操场"}],` + + `"visible":true,"side":"up","widthPercent":999,"showNote":true,` + + `"instanceOverrides":{"browser-1":"off","browser-2":"maybe","browser-3":"inherit"}}`) + if cfg == nil { + t.Fatal("结构合法的部分应读出来") + } + if len(cfg.Items) != 3 || cfg.Items[0].Title != "开幕式" || cfg.Items[2].Note != "操场" { + t.Fatalf("只有标题为空的条目该丢掉: %+v", cfg.Items) + } + if got := cfg.Items[1]; got.Title != "时间待定" || got.Time != "" { + t.Fatalf("时间为空的条目应保留且 time 是空串: %+v", got) + } + if cfg.Side != ScheduleSideRight || cfg.WidthPercent != MaxScheduleWidthPercent { + t.Fatalf("左右应回落 right、宽度应夹到 40: %+v", cfg) + } + if !cfg.Visible || !cfg.ShowNote { + t.Fatalf("开关应原样读出: %+v", cfg) + } + // 非三态的值丢掉;`inherit` 也丢掉(它就是「没有这条设置」,留着只会白占条数预算)。 + if len(cfg.InstanceOverrides) != 1 || cfg.InstanceOverrides["browser-1"] != ScheduleOverrideOff { + t.Fatalf("非三态与 inherit 的覆盖都该丢掉: %+v", cfg.InstanceOverrides) + } + + // 越界的条数只取前 N 条,不报错。 + var sb strings.Builder + sb.WriteString(`{"items":[`) + for i := 0; i < MaxScheduleItems+1; i++ { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(`{"time":"08:30","title":"项"}`) + } + sb.WriteString(`]}`) + if got := ParseScheduleValue(sb.String()); len(got.Items) != MaxScheduleItems { + t.Fatalf("条目应截到 %d 条,得到 %d", MaxScheduleItems, len(got.Items)) + } +} + +// 归一函数是读写两侧共用的口径,单独钉住边界(写侧夹取、读侧容错都走它)。 +func TestNormalizeScheduleSideAndWidth(t *testing.T) { + for raw, want := range map[string]string{ + "": "right", "right": "right", "left": "left", "LEFT": "left", " Left ": "left", + "up": "right", "左右": "right", + } { + if got := NormalizeScheduleSide(raw); got != want { + t.Fatalf("NormalizeScheduleSide(%q) = %q,期望 %q", raw, got, want) + } + } + for raw, want := range map[float64]float64{ + 0: DefaultScheduleWidthPercent, -3: DefaultScheduleWidthPercent, + 1: MinScheduleWidthPercent, 14.9: MinScheduleWidthPercent, + 22: 22, 40: 40, 100: MaxScheduleWidthPercent, + } { + if got := NormalizeScheduleWidth(raw); got != want { + t.Fatalf("NormalizeScheduleWidth(%v) = %v,期望 %v", raw, got, want) + } + } +} + +// 线上形状:JSON 键一律小驼峰(与既有 display.* 键一致), +// items 空时是 [] 而不是 null —— 前端按数组解析,null 会当场炸。 +func TestScheduleConfigJSONShape(t *testing.T) { + raw, err := json.Marshal(ScheduleConfig{ + Items: []ScheduleItem{}, Side: ScheduleSideRight, WidthPercent: 22, + }) + if err != nil { + t.Fatalf("序列化: %v", err) + } + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + t.Fatalf("解析: %v", err) + } + for _, key := range []string{"items", "visible", "side", "widthPercent", "showNote"} { + if _, ok := top[key]; !ok { + t.Fatalf("缺少小驼峰键 %s: %s", key, raw) + } + } + if string(top["items"]) != "[]" { + t.Fatalf("空日程应序列化为 []: %s", raw) + } + // 单屏覆盖可缺:全是 inherit 时不必写出来(写侧只在有内容时才建 map)。 + if _, ok := top["instanceOverrides"]; ok { + t.Fatalf("没有覆盖时不该出现 instanceOverrides: %s", raw) + } + // 可缺字段:note / done 缺省时不下线。 + itemRaw, err := json.Marshal(ScheduleItem{Time: "08:30", Title: "开幕式"}) + if err != nil { + t.Fatalf("序列化条目: %v", err) + } + var item map[string]json.RawMessage + if err := json.Unmarshal(itemRaw, &item); err != nil { + t.Fatalf("解析条目: %v", err) + } + if len(item) != 2 { + t.Fatalf("缺省字段不该上线,得到 %s", itemRaw) + } +} diff --git a/server/internal/httpapi/display_schedule_test.go b/server/internal/httpapi/display_schedule_test.go new file mode 100644 index 0000000..b75fb82 --- /dev/null +++ b/server/internal/httpapi/display_schedule_test.go @@ -0,0 +1,650 @@ +package httpapi_test + +import ( + "database/sql" + "encoding/json" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" +) + +// 一份最小可用的日程配置:含中文、含 note / done 缺省、含单屏覆盖。 +// 字段名与 web/src/shared/types.ts 的 ScheduleSidebarConfig 逐字对应。 +func schedulePayload() map[string]any { + return map[string]any{ + "items": []map[string]any{ + {"time": "08:30", "title": "男子 100 米预赛", "note": "检录 08:00", "done": true}, + {"time": "上午", "title": "开幕式"}, + {"time": "待定", "title": "闭幕式", "done": false}, + }, + "visible": true, + "side": "left", + "widthPercent": 22, + "showNote": true, + "instanceOverrides": map[string]any{"browser-1": "on", "browser-2": "off"}, + } +} + +// postSchedule 下发一条 schedule 指令;csrf 只在已登录请求上带(未登录请求没有 CSRF Cookie)。 +func postSchedule(t *testing.T, env *testEnv, client *http.Client, payload any, csrf bool) (int, envelope) { + t.Helper() + return env.do(client, http.MethodPost, "/api/display/command", + map[string]any{"action": "schedule", "payload": payload}, csrf) +} + +// 日程保存:**先转发、再落库**,并留下 settings 变更(大屏靠它 resync 才看得到)。 +// 落库的 JSON 形状要与前端类型逐字对上:小驼峰键、note/done 可缺、中文原样。 +func TestDisplayCommandScheduleForwardsThenPersists(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + before := changeLogCount(t, env, "settings", "upsert") + + status, env2 := postSchedule(t, env, client, schedulePayload(), true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("保存日程应 200,得到 %d %+v", status, env2.Error) + } + // 转发:大屏要立刻看到侧边栏(热开关),不能只落库等下次快照。 + if len(sender.sent) != 1 || sender.sent[0] != realtime.CmdSchedule { + t.Fatalf("日程必须下发给实时层,得到 %+v", sender.sent) + } + if len(sender.targets) != 1 || sender.targets[0] != nil { + t.Fatalf("不带 targets 时应按广播下发(nil),得到 %+v", sender.targets) + } + // 落库:只写 settings 行不会有广播,必须有这条 change_log,且它的载荷要带上日程 + // ——大屏就是靠这条变更重拉设置、把侧边栏显示出来的。 + if got := changeLogCount(t, env, "settings", "upsert"); got != before+1 { + t.Fatalf("保存日程应新增 1 条 settings change_log,得到 %d → %d", before, got) + } + var payload string + if err := env.ev.QueryRow( + `SELECT payload_json FROM change_log WHERE entity_type = 'settings' AND action = 'upsert' + ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil { + t.Fatalf("读 change_log 载荷: %v", err) + } + if !strings.Contains(payload, `"schedule"`) || !strings.Contains(payload, "男子 100 米预赛") { + t.Fatalf("settings 变更的载荷应带上整份日程,得到 %s", payload) + } + + raw := settingValue(t, env, "display.schedule") + var stored map[string]any + if err := json.Unmarshal([]byte(raw), &stored); err != nil { + t.Fatalf("落库值应是 JSON: %v (%s)", err, raw) + } + for _, key := range []string{"items", "visible", "side", "widthPercent", "showNote", "instanceOverrides"} { + if _, ok := stored[key]; !ok { + t.Fatalf("落库 JSON 缺少小驼峰键 %s: %s", key, raw) + } + } + if stored["visible"] != true || stored["side"] != "left" || stored["showNote"] != true { + t.Fatalf("开关与左右应原样落库: %s", raw) + } + if stored["widthPercent"].(float64) != 22 { + t.Fatalf("宽度应落库为 22: %s", raw) + } + items := stored["items"].([]any) + if len(items) != 3 { + t.Fatalf("应落库 3 条日程,得到 %d: %s", len(items), raw) + } + first := items[0].(map[string]any) + if first["time"] != "08:30" || first["title"] != "男子 100 米预赛" || + first["note"] != "检录 08:00" || first["done"] != true { + t.Fatalf("第一条日程应原样落库: %+v", first) + } + // note / done 是可缺字段:没填的那条不该被补成空串,前端按可选字段解析。 + second := items[1].(map[string]any) + if _, ok := second["note"]; ok { + t.Fatalf("没填备注的条目不该带 note 键: %+v", second) + } + if _, ok := second["done"]; ok { + t.Fatalf("未勾选的条目不该带 done 键: %+v", second) + } + overrides := stored["instanceOverrides"].(map[string]any) + if overrides["browser-1"] != "on" || overrides["browser-2"] != "off" { + t.Fatalf("单屏覆盖应原样落库: %+v", overrides) + } + + audit := waitDisplayAudit(t, env) + for _, want := range []string{`"action":"schedule"`, `"matched":1`} { + if !strings.Contains(audit, want) { + t.Fatalf("审计应含 %s,得到 %s", want, audit) + } + } +} + +// 保存 → 读回一致:管理端与快照走的是同一个 ParseEventSettings,读回来的形状必须与写进去的一样。 +func TestDisplayCommandScheduleRoundTrip(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + env.createUser("boss", "boss-pass", "admin") + boss, _ := env.client() + env.login(boss, "boss", "boss-pass") + + if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK { + t.Fatalf("保存日程应 200,得到 %d %+v", status, e.Error) + } + + _, env2 := env.do(boss, http.MethodGet, "/api/settings", nil, false) + settings := decodeData[map[string]any](t, env2) + schedule, ok := settings["display"].(map[string]any)["schedule"].(map[string]any) + if !ok { + t.Fatalf("读回的 settings 应带 display.schedule: %+v", settings["display"]) + } + if schedule["visible"] != true || schedule["side"] != "left" || schedule["showNote"] != true { + t.Fatalf("读回的开关 / 左右 / 备注开关应与写进去的一致: %+v", schedule) + } + if schedule["widthPercent"].(float64) != 22 { + t.Fatalf("读回的宽度应为 22: %+v", schedule) + } + items := schedule["items"].([]any) + if len(items) != 3 { + t.Fatalf("读回 3 条日程,得到 %d: %+v", len(items), items) + } + last := items[2].(map[string]any) + if last["time"] != "待定" || last["title"] != "闭幕式" { + t.Fatalf("手填的自由时间文本(「待定」)应原样读回: %+v", last) + } + // done 是可缺字段:false 不下线,前端按「没这个键 = 未完成」解析(这也是「当前项」的判据)。 + if v, ok := last["done"]; ok { + t.Fatalf("未完成项不该带 done 键,得到 %+v", v) + } + if overrides := schedule["instanceOverrides"].(map[string]any); overrides["browser-2"] != "off" { + t.Fatalf("单屏覆盖应原样读回: %+v", schedule) + } +} + +// 上限与非法输入:一律 400 + 中文提示,且**不转发、不落库**。 +// 静默截断会让操作员以为配置生效了,所以这里必须拒绝而不是存一半。 +func TestDisplayCommandScheduleRejectsOverLimit(t *testing.T) { + items := func(n int) []map[string]any { + out := make([]map[string]any, 0, n) + for i := 0; i < n; i++ { + out = append(out, map[string]any{"time": "08:30", "title": "第 " + strings.Repeat("x", i%3) + " 项"}) + } + return out + } + payloadWith := func(mutate func(map[string]any)) map[string]any { + p := schedulePayload() + mutate(p) + return p + } + + for _, tc := range []struct { + name string + payload any + want string + }{ + {"条目超过 40 条", payloadWith(func(p map[string]any) { p["items"] = items(41) }), "日程条目最多 40 条"}, + {"标题为空", payloadWith(func(p map[string]any) { + p["items"] = []map[string]any{{"time": "08:30", "title": "\n\x00"}} + }), "第 1 条日程的标题不能为空"}, + {"时间超过 20 字", payloadWith(func(p map[string]any) { + p["items"] = []map[string]any{{"time": strings.Repeat("时", 21), "title": "开幕式"}} + }), "时间最长 20 个字符"}, + {"标题超过 40 字", payloadWith(func(p map[string]any) { + p["items"] = []map[string]any{{"time": "08:30", "title": strings.Repeat("赛", 41)}} + }), "标题最长 40 个字符"}, + {"备注超过 60 字", payloadWith(func(p map[string]any) { + p["items"] = []map[string]any{{"time": "08:30", "title": "开幕式", "note": strings.Repeat("备", 61)}} + }), "备注最长 60 个字符"}, + {"第二项越界也要指出是第几条", payloadWith(func(p map[string]any) { + p["items"] = []map[string]any{ + {"time": "08:30", "title": "开幕式"}, + {"time": "09:00", "title": strings.Repeat("赛", 41)}, + } + }), "第 2 条日程的标题最长 40 个字符"}, + {"单屏覆盖超过 50 条", payloadWith(func(p map[string]any) { + overrides := map[string]any{} + for i := 0; i < 51; i++ { + overrides["screen-"+string(rune('a'+i%26))+strings.Repeat("x", i/26)] = "on" + } + p["instanceOverrides"] = overrides + }), "单屏覆盖最多 50 条"}, + {"单屏覆盖取值不是三态", payloadWith(func(p map[string]any) { + p["instanceOverrides"] = map[string]any{"browser-1": "maybe"} + }), "取值只能是 inherit / on / off"}, + {"单屏覆盖的实例 id 超长", payloadWith(func(p map[string]any) { + p["instanceOverrides"] = map[string]any{strings.Repeat("i", 121): "on"} + }), "实例 id 最长 120 个字符"}, + {"payload 不是对象", []any{1, 2, 3}, "格式不正确"}, + } { + t.Run(tc.name, func(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + before := changeLogCount(t, env, "settings", "upsert") + + status, env2 := postSchedule(t, env, client, tc.payload, true) + if status != http.StatusBadRequest || env2.Error == nil { + t.Fatalf("非法日程应 400,得到 %d %+v", status, env2.Error) + } + if env2.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("错误码应为 VALIDATION_FAILED,得到 %+v", env2.Error) + } + if !strings.Contains(env2.Error.Message, tc.want) { + t.Fatalf("提示应含 %q(说清哪一项超限),得到 %q", tc.want, env2.Error.Message) + } + if len(sender.sent) != 0 { + t.Fatalf("非法日程不该下发(否则大屏先画一帧错的),得到 %+v", sender.sent) + } + if got := settingOrEmpty(t, env, "display.schedule"); got != "" { + t.Fatalf("非法日程不该落库,得到 %q", got) + } + if got := changeLogCount(t, env, "settings", "upsert"); got != before { + t.Fatalf("非法日程不该产生 settings 变更,得到 %d → %d", before, got) + } + }) + } +} + +// 时间**允许为空**:管理端的 validateSchedule 只卡时间长度、不卡必填(「先把标题排上、 +// 时间回头补」是现场的常见流程),服务端必须同口径 —— 否则「校验不过时一个请求都不发」 +// 这条承诺在这一格上不成立,操作员会先看到请求发出去、再被退一句 400。 +// 空串 / 全空白 / 只含控制字符都算空,落库就是空串(不是特殊占位,也不是整条丢掉)。 +func TestDisplayCommandScheduleAllowsEmptyTime(t *testing.T) { + for _, tc := range []struct { + name string + time string + }{ + {"空串", ""}, + {"全空白", " "}, + {"只含控制字符", "\n\t\x00"}, + } { + t.Run(tc.name, func(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + + status, env2 := postSchedule(t, env, client, map[string]any{ + "items": []map[string]any{{"time": tc.time, "title": "闭幕式"}}, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("时间为空应保存成功,得到 %d %+v", status, env2.Error) + } + if len(sender.sent) != 1 || sender.sent[0] != realtime.CmdSchedule { + t.Fatalf("时间为空的日程也要下发给大屏,得到 %+v", sender.sent) + } + var stored struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil { + t.Fatalf("解析落库值: %v", err) + } + if len(stored.Items) != 1 { + t.Fatalf("条目应保留,得到 %+v", stored.Items) + } + if got, ok := stored.Items[0]["time"]; !ok || got != "" { + t.Fatalf("时间应原样落库为空串(键要在、值要是空串),得到 %+v", stored.Items[0]) + } + if stored.Items[0]["title"] != "闭幕式" { + t.Fatalf("标题应原样落库: %+v", stored.Items[0]) + } + }) + } +} + +// showNote 缺省 = **true**(备注默认上屏):没带这个字段的客户端不能把备注悄悄关掉。 +// bool 零值分不出「键没带」与「显式 false」,所以这条用例两种都要钉。 +func TestDisplayCommandScheduleShowNoteDefaultsTrue(t *testing.T) { + for _, tc := range []struct { + name string + body map[string]any + want bool + }{ + {"不带 showNote", map[string]any{}, true}, + {"显式 true", map[string]any{"showNote": true}, true}, + {"显式 false", map[string]any{"showNote": false}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + env.createUser("boss", "boss-pass", "admin") + boss, _ := env.client() + env.login(boss, "boss", "boss-pass") + + body := map[string]any{"items": []map[string]any{{"time": "08:30", "title": "开幕式"}}} + for k, v := range tc.body { + body[k] = v + } + if status, e := postSchedule(t, env, client, body, true); status != http.StatusOK { + t.Fatalf("应 200,得到 %d %+v", status, e.Error) + } + // 落库的 JSON 里必须是显式的布尔(不吃零值),再看读回的口径。 + var stored struct { + ShowNote *bool `json:"showNote"` + } + if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil { + t.Fatalf("解析落库值: %v", err) + } + if stored.ShowNote == nil || *stored.ShowNote != tc.want { + t.Fatalf("落库的 showNote 应为 %v,得到 %v", tc.want, stored.ShowNote) + } + _, env2 := env.do(boss, http.MethodGet, "/api/settings", nil, false) + schedule := decodeData[map[string]any](t, env2)["display"].(map[string]any)["schedule"].(map[string]any) + if schedule["showNote"] != tc.want { + t.Fatalf("读回的 showNote 应为 %v,得到 %v", tc.want, schedule["showNote"]) + } + }) + } + + // 类型不对(不是布尔)按「格式不正确」挡在门外,而不是悄悄当开: + // 写入口是人在操作,含糊放过一个坏 payload 只会让现场更难看懂。 + // (读侧没有出口,遇到这种值就回落 true,见 dto.ScheduleShowNote。) + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + status, env2 := postSchedule(t, env, client, map[string]any{ + "items": []map[string]any{{"time": "08:30", "title": "开幕式"}}, "showNote": "no", + }, true) + if status != http.StatusBadRequest || env2.Error == nil { + t.Fatalf("showNote 不是布尔应 400,得到 %d %+v", status, env2.Error) + } + if !strings.Contains(env2.Error.Message, "格式不正确") { + t.Fatalf("提示应说清是格式问题,得到 %q", env2.Error.Message) + } +} + +// `inherit` 是「没有这条设置」(缺省就是它):写侧接受但不落库,绝不白占 50 条预算。 +func TestDisplayCommandScheduleDropsInheritOverrides(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + + status, env2 := postSchedule(t, env, client, map[string]any{ + "items": []map[string]any{{"time": "08:30", "title": "开幕式"}}, + "instanceOverrides": map[string]any{"browser-1": "inherit", "browser-2": "on"}, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("inherit 应被接受(不算非法),得到 %d %+v", status, env2.Error) + } + var stored struct { + InstanceOverrides map[string]string `json:"instanceOverrides"` + } + if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil { + t.Fatalf("解析落库值: %v", err) + } + if len(stored.InstanceOverrides) != 1 || stored.InstanceOverrides["browser-2"] != "on" { + t.Fatalf("只该留下 browser-2=on,得到 %+v", stored.InstanceOverrides) + } + + // 全是 inherit 时整块键都不该出现(前端按可选字段解析)。 + if status, e := postSchedule(t, env, client, map[string]any{ + "items": []map[string]any{{"time": "08:30", "title": "开幕式"}}, + "instanceOverrides": map[string]any{"browser-1": "inherit"}, + }, true); status != http.StatusOK { + t.Fatalf("应 200,得到 %d %+v", status, e.Error) + } + if raw := settingValue(t, env, "display.schedule"); strings.Contains(raw, "instanceOverrides") { + t.Fatalf("全是 inherit 时不该落库这个键: %s", raw) + } +} + +// 清洗:控制字符一律剥掉(复用 realtime.CleanUserText),首尾空白去掉; +// 归一:widthPercent 夹到 15–40、缺省回落默认宽度,side 认不出时回落 right。 +func TestDisplayCommandScheduleCleansAndClamps(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + + status, env2 := postSchedule(t, env, client, map[string]any{ + "items": []map[string]any{ + {"time": " 08:30\t", "title": "男子\n100\x00米预赛", "note": "检录\r\n08:00", "done": true}, + }, + "visible": true, + "side": "middle", + "widthPercent": 100, + "showNote": true, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("保存在上限内的日程应 200,得到 %d %+v", status, env2.Error) + } + + var stored struct { + Items []struct { + Time string `json:"time"` + Title string `json:"title"` + Note string `json:"note"` + Done bool `json:"done"` + } `json:"items"` + Side string `json:"side"` + WidthPercent float64 `json:"widthPercent"` + } + if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil { + t.Fatalf("解析落库值: %v", err) + } + if stored.Items[0].Time != "08:30" || stored.Items[0].Title != "男子100米预赛" || + stored.Items[0].Note != "检录08:00" { + t.Fatalf("控制字符应被剥掉、首尾空白应去掉: %+v", stored.Items[0]) + } + if !stored.Items[0].Done { + t.Fatalf("已完成应原样保留: %+v", stored.Items[0]) + } + if stored.WidthPercent != 40 { + t.Fatalf("线上限 40:widthPercent 100 应夹到 40,得到 %v", stored.WidthPercent) + } + if stored.Side != "right" { + t.Fatalf("认不出的 side 应回落 right,得到 %q", stored.Side) + } + // 转发的也是归一后的值(否则大屏会先按 100% 画一帧)。 + if audit := waitDisplayAudit(t, env); !strings.Contains(audit, `"widthPercent":40`) { + t.Fatalf("审计里的配置应是归一后的值,得到 %s", audit) + } + + // 下上限与缺省。 + for _, tc := range []struct { + name string + body map[string]any + side string + width float64 + }{ + {"过窄夹到 15", map[string]any{"items": []any{}, "side": "left", "widthPercent": 5}, "left", 15}, + {"缺省回落 22", map[string]any{"items": []any{}}, "right", 22}, + {"负数回落 22", map[string]any{"items": []any{}, "widthPercent": -3}, "right", 22}, + {"大小写不敏感", map[string]any{"items": []any{}, "side": "LEFT"}, "left", 22}, + } { + t.Run(tc.name, func(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + if status, e := postSchedule(t, env, client, tc.body, true); status != http.StatusOK { + t.Fatalf("应 200,得到 %d %+v", status, e.Error) + } + var got struct { + Side string `json:"side"` + WidthPercent float64 `json:"widthPercent"` + } + if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &got); err != nil { + t.Fatalf("解析落库值: %v", err) + } + if got.Side != tc.side || got.WidthPercent != tc.width { + t.Fatalf("归一结果应为 side=%s width=%v,得到 side=%s width=%v", + tc.side, tc.width, got.Side, got.WidthPercent) + } + }) + } +} + +// 权限:与大屏公告(同一条 display:command 通道)同档 —— 未登录 401、录入员 200、管理员 200。 +// 两种 action 各发一次,用同一次请求比对状态码,避免「档位被悄悄改动」看不出来。 +func TestDisplayCommandSchedulePermissionMatchesAnnounce(t *testing.T) { + sender := &strictDisplaySender{} + env := newTestEnvWith(t, func(o *httpapi.Options) { o.Display = sender }) + env.createUser("recorder", "recorder-pass", "recorder") + env.createUser("boss", "boss-pass", "admin") + + anon, _ := env.client() + // 未登录请求不带 CSRF(中间件对 guest 请求直接放行,由 RequireWrite 判权限)。 + scheduleStatus, _ := postSchedule(t, env, anon, schedulePayload(), false) + announceStatus, _ := env.do(anon, http.MethodPost, "/api/display/command", + map[string]any{"action": "announce", "payload": map[string]any{"text": "决赛推迟"}}, false) + if scheduleStatus != announceStatus { + t.Fatalf("日程与公告应同档:未登录日程 %d,公告 %d", scheduleStatus, announceStatus) + } + if scheduleStatus != http.StatusUnauthorized { + t.Fatalf("未登录应被挡下(写接口未登录一律 401),得到 %d", scheduleStatus) + } + + for _, who := range []struct{ user, pass string }{{"recorder", "recorder-pass"}, {"boss", "boss-pass"}} { + client, _ := env.client() + env.login(client, who.user, who.pass) + if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK { + t.Fatalf("%s 保存日程应 200,得到 %d %+v", who.user, status, e.Error) + } + } +} + +// 定点点名的屏全离线:与既有 action 同样的 404,且日程不落库、不产生 settings 变更。 +func TestDisplayCommandScheduleOfflineTargetReturns404(t *testing.T) { + matched := 0 + sender := &strictDisplaySender{matched: &matched} + env, client := newDisplayEnv(t, sender) + before := changeLogCount(t, env, "settings", "upsert") + + status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{ + "action": "schedule", + "payload": schedulePayload(), + "targets": []string{"gone-screen"}, + }, true) + if status != http.StatusNotFound || env2.Error == nil { + t.Fatalf("目标离线应 404,得到 %d %+v", status, env2.Error) + } + if env2.Error.Message != "目标大屏已离线,请刷新在线列表" { + t.Fatalf("404 消息应与既有 action 一致,得到 %q", env2.Error.Message) + } + if got := settingOrEmpty(t, env, "display.schedule"); got != "" { + t.Fatalf("没命中任何屏时日程不该落库,得到 %q", got) + } + if got := changeLogCount(t, env, "settings", "upsert"); got != before { + t.Fatalf("定向失败不该产生 settings 变更,得到 %d → %d", before, got) + } +} + +// 读取侧容错:库里那个值被改坏(手工改库 / 写到一半断电)时,settings 照常 200、 +// display.schedule 当空,大屏按「没有这个功能」处理 —— 一条脏设置绝不能把大屏打不开。 +func TestSettingsTolerantToBrokenScheduleValue(t *testing.T) { + env := newTestEnv(t) + env.createUser("boss", "boss-pass", "admin") + client, _ := env.client() + env.login(client, "boss", "boss-pass") + + writeRaw := func(raw string) { + t.Helper() + if _, err := env.ev.Exec( + `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, + "display.schedule", raw); err != nil { + t.Fatalf("写入配置: %v", err) + } + } + readSchedule := func() (map[string]any, bool) { + t.Helper() + status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("坏数据下 settings 仍应 200,得到 %d %+v", status, env2.Error) + } + display := decodeData[map[string]any](t, env2)["display"].(map[string]any) + v, ok := display["schedule"].(map[string]any) + return v, ok + } + + for _, tc := range []struct{ name, raw string }{ + {"坏 JSON", `{items: [`}, + {"不是对象", `"日程"`}, + {"截断的 JSON", `{"items":[{"time":"08:30","title":"开幕`}, + } { + writeRaw(tc.raw) + if _, ok := readSchedule(); ok { + t.Fatalf("%s:坏数据应读成 null(当空),得到非空值", tc.name) + } + } + + // 结构上不成立的部分丢掉:**标题**缺失的条目、非三态的覆盖,越界的条数取前 40。 + // 时间为空的条目是合法的,必须留着(与写入口、管理端同口径),`time` 读成空串。 + writeRaw(`{"items":[{"time":"08:30","title":"开幕式"},{"time":"","title":"时间待定"},` + + `{"time":"09:00","title":""},{"title":"连 time 键都没有"}],` + + `"visible":true,"side":"up","widthPercent":999,"showNote":false,` + + `"instanceOverrides":{"browser-1":"on","browser-2":"maybe","":"on"}}`) + got, ok := readSchedule() + if !ok { + t.Fatal("结构合法的部分应照样读出来") + } + items := got["items"].([]any) + if len(items) != 3 { + t.Fatalf("只有标题为空的条目该丢掉,应留 3 条,得到 %+v", items) + } + if empty := items[1].(map[string]any); empty["title"] != "时间待定" || empty["time"] != "" { + t.Fatalf("时间为空的条目应保留且 time 读成空串,得到 %+v", empty) + } + if noKey := items[2].(map[string]any); noKey["title"] != "连 time 键都没有" || noKey["time"] != "" { + t.Fatalf("连 time 键都没有的条目也该保留(当空串),得到 %+v", noKey) + } + if got["side"] != "right" { + t.Fatalf("认不出的 side 读侧也应回落 right,得到 %v", got["side"]) + } + if got["widthPercent"].(float64) != 40 { + t.Fatalf("越界宽度读侧应夹到 40,得到 %v", got["widthPercent"]) + } + if got["visible"] != true { + t.Fatalf("总开关应照样读出来: %+v", got) + } + overrides := got["instanceOverrides"].(map[string]any) + if len(overrides) != 1 || overrides["browser-1"] != "on" { + t.Fatalf("非三态与空 id 的覆盖应丢掉,只留 browser-1: %+v", overrides) + } + + // 条数越界:读侧只取前 40 条,不报错。 + var sb strings.Builder + sb.WriteString(`{"items":[`) + for i := 0; i < 41; i++ { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(`{"time":"08:30","title":"项"}`) + } + sb.WriteString(`],"visible":true}`) + writeRaw(sb.String()) + got, ok = readSchedule() + if !ok { + t.Fatal("条数越界时其余字段仍应读出来") + } + if got := got["items"].([]any); len(got) != 40 { + t.Fatalf("读侧应只取前 40 条,得到 %d", len(got)) + } +} + +// 未接入实时层时(s.display == nil)日程照样落库:大屏后连上来靠的是 settings + 快照。 +func TestDisplayCommandSchedulePersistsWithoutRealtime(t *testing.T) { + env := newTestEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK { + t.Fatalf("没有实时层时应落库并 200,得到 %d %+v", status, e.Error) + } + var n int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM settings WHERE key = 'display.schedule'`).Scan(&n); err != nil { + t.Fatalf("查配置: %v", err) + } + if n != 1 { + t.Fatalf("日程应落库,得到 %d 行", n) + } +} + +// 漏了 payload(客户端 bug)时给中文 400,而不是把大屏切成一个空日程。 +func TestDisplayCommandScheduleRequiresPayload(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + + status, env2 := env.do(client, http.MethodPost, "/api/display/command", + map[string]any{"action": "schedule"}, true) + if status != http.StatusBadRequest || env2.Error == nil { + t.Fatalf("缺 payload 应 400,得到 %d %+v", status, env2.Error) + } + if !strings.Contains(env2.Error.Message, "缺少 payload") { + t.Fatalf("提示应说清缺什么,得到 %q", env2.Error.Message) + } + if len(sender.sent) != 0 { + t.Fatalf("缺 payload 不该下发,得到 %+v", sender.sent) + } + var v sql.NullString + if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.schedule'`).Scan(&v); err == nil { + t.Fatalf("缺 payload 不该落库,得到 %q", v.String) + } +} diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index 524a555..2e2f267 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -1,12 +1,16 @@ package httpapi import ( + "encoding/json" "errors" "fmt" "net/http" + "sort" "strings" + "unicode/utf8" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" ) @@ -318,6 +322,127 @@ func announceFromPayload(payload any) (string, int) { return text, int(seconds) } +// scheduleFromPayload 把 schedule 指令的 payload 解成整份日程配置,并做完写入口的校验与归一。 +// +// 返回的 error 是给现场看的**中文提示**,调用方按 400 处理。口径: +// - 条目数、文本长度、单屏覆盖条数与取值超限一律**拒绝**,不静默截断 —— +// 悄悄存一半会让人以为配置已经生效; +// - 只有两类按口径归一(夹取 / 回落,没有信息损失):widthPercent 夹到 15–40 +// (缺省或非正数回落默认宽度)、side 认不出时回落 right。 +// +// 文本一律先过 realtime.CleanUserText(去控制字符 + 去首尾空白,与设备名、大屏别名共用一个收口): +// 一条带换行的标题能把大屏那一列顶歪,而服务端是唯一的收口处。清洗后只判**标题**必填 +// (必填口径与管理端 scheduleEditor.ts 的 validateSchedule 逐字一致,那边时间也只卡长度)。 +// +// 单屏覆盖的键是实例 id,**不**做控制字符剥离:剥完就不再是那台设备的 id,覆盖会静默落空; +// 含控制字符的键直接判非法,让现场看见。 +// +// 两处「缺省不等于零值」在这里收口:showNote 没带就是 true(备注默认上屏)、 +// 值为 inherit 的覆盖不进 map(它就是「没有这条设置」)。 +func scheduleFromPayload(payload any) (dto.ScheduleConfig, error) { + if payload == nil { + return dto.ScheduleConfig{}, errors.New("schedule 指令缺少 payload:需要整份日程配置") + } + // payload 在请求体里是 any,再走一遍 JSON 才能按结构体类型解出来(类型不对在这里就报错)。 + raw, err := json.Marshal(payload) + if err != nil { + return dto.ScheduleConfig{}, errors.New("日程配置无法解析") + } + var cfg dto.ScheduleConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return dto.ScheduleConfig{}, errors.New( + "日程配置格式不正确:payload 应是一个对象(items / visible / side / widthPercent / showNote / instanceOverrides)") + } + // showNote 缺省 = true(备注默认上屏):bool 零值分不出「键没带」与「显式 false」, + // 这里必须回头看一眼原始 JSON,否则漏字段的客户端会把备注悄悄关掉。 + showNote := dto.ScheduleShowNote(raw) + + if len(cfg.Items) > dto.MaxScheduleItems { + return dto.ScheduleConfig{}, fmt.Errorf("日程条目最多 %d 条(当前 %d 条)", + dto.MaxScheduleItems, len(cfg.Items)) + } + items := make([]dto.ScheduleItem, 0, len(cfg.Items)) + for i, it := range cfg.Items { + // 序号按**用户看到的顺序**从 1 数起:提示里说「第 3 条」才能对上编辑面板里那一行。 + no := i + 1 + time := realtime.CleanUserText(it.Time) + title := realtime.CleanUserText(it.Title) + note := realtime.CleanUserText(it.Note) + switch { + // 时间**允许为空**(管理端 `validateSchedule` 同口径,定稿口径是「时间完全手填」): + // 现场常见「先把标题排上、时间回头补」,空的只是不上屏显示时间那一格,不是错误数据。 + case utf8.RuneCountInString(time) > dto.MaxScheduleTimeChars: + return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的时间最长 %d 个字符(当前 %d 个)", + no, dto.MaxScheduleTimeChars, utf8.RuneCountInString(time)) + case title == "": + return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的标题不能为空", no) + case utf8.RuneCountInString(title) > dto.MaxScheduleTitleChars: + return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的标题最长 %d 个字符(当前 %d 个)", + no, dto.MaxScheduleTitleChars, utf8.RuneCountInString(title)) + case utf8.RuneCountInString(note) > dto.MaxScheduleNoteChars: + return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的备注最长 %d 个字符(当前 %d 个)", + no, dto.MaxScheduleNoteChars, utf8.RuneCountInString(note)) + } + // 清空的时间原样存空串(不带 omitempty 以外的替换):大屏看到空串就不画时间那一格。 + items = append(items, dto.ScheduleItem{Time: time, Title: title, Note: note, Done: it.Done}) + } + + if len(cfg.InstanceOverrides) > dto.MaxScheduleOverrides { + return dto.ScheduleConfig{}, fmt.Errorf("单屏覆盖最多 %d 条(当前 %d 条)", + dto.MaxScheduleOverrides, len(cfg.InstanceOverrides)) + } + // 按 id 排序后再校验:map 的遍历顺序是随机的,不排序的话「先报哪一条」每次都不一样, + // 现场按提示去改的那一条可能下一次就不是它了。 + var overrides map[string]string + if len(cfg.InstanceOverrides) > 0 { + ids := make([]string, 0, len(cfg.InstanceOverrides)) + for id := range cfg.InstanceOverrides { + ids = append(ids, id) + } + sort.Strings(ids) + overrides = make(map[string]string, len(ids)) + for _, id := range ids { + value := cfg.InstanceOverrides[id] + if realtime.CleanUserText(id) != strings.TrimSpace(id) { + return dto.ScheduleConfig{}, fmt.Errorf("单屏覆盖的实例 id %q 含有非法字符", id) + } + key := strings.TrimSpace(id) + if key == "" { + return dto.ScheduleConfig{}, errors.New("单屏覆盖的实例 id 不能为空") + } + if utf8.RuneCountInString(key) > dto.MaxScheduleOverrideKeyChars { + return dto.ScheduleConfig{}, fmt.Errorf("单屏覆盖的实例 id 最长 %d 个字符(当前 %d 个)", + dto.MaxScheduleOverrideKeyChars, utf8.RuneCountInString(key)) + } + switch value { + case dto.ScheduleOverrideInherit, dto.ScheduleOverrideOn, dto.ScheduleOverrideOff: + default: + return dto.ScheduleConfig{}, fmt.Errorf( + "单屏覆盖 %s 的取值只能是 inherit / on / off(当前 %q)", key, value) + } + // inherit 是「没有这条设置」(缺省就是它):接受但**不落库**,否则它会白占 + // MaxScheduleOverrides 的条数预算 —— 管理端与大屏解析都把这种键当不存在, + // 只有服务端存着的话,攒够 50 条谁都再加不了覆盖。 + if value == dto.ScheduleOverrideInherit { + continue + } + overrides[key] = value + } + if len(overrides) == 0 { + overrides = nil + } + } + + return dto.ScheduleConfig{ + Items: items, + Visible: cfg.Visible, + Side: dto.NormalizeScheduleSide(cfg.Side), + WidthPercent: dto.NormalizeScheduleWidth(cfg.WidthPercent), + ShowNote: showNote, + InstanceOverrides: overrides, + }, nil +} + // displayCommandAudit 是 display.command 的审计载荷:指令内容原样保留(现场按 action 检索的 // 既有查询不受影响),另外记上定向目标与实际命中的实例数。 type displayCommandAudit struct { @@ -333,6 +458,19 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) ( if in.Action == "" { return nil, badRequest("action 必填", nil) } + // 日程是整份替换,**校验必须排在转发之前**:非法配置一旦先发出去,大屏会先照着一份越界的 + // 日程画上一帧,然后才被服务端否定。校验通过的对象在这里就定下来,下面只管转发与落库。 + var schedule dto.ScheduleConfig + if in.Action == realtime.CmdSchedule { + cfg, err := scheduleFromPayload(in.Payload) + if err != nil { + return nil, badRequest(err.Error(), nil) + } + schedule = cfg + // 转发的是**归一后**的配置:客户端把 widthPercent 填成 100 时落库是 40, + // 若把原值直接发给大屏,那块屏会先按 100% 画一帧、再被快照纠回来。 + in.Payload = cfg + } matched := 0 // 没接实时层(s.display == nil)时不转发,但下面的公告落库照旧:公告能被后连上来的 // 大屏读到靠的是 settings + change_log,与这一次广播无关。 @@ -368,6 +506,12 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) ( if err := s.clearAnnouncement(r.Context(), actorID); err != nil { return nil, err } + // 日程与公告同一个形状:上面已经把指令转发出去了,这里再把持久值写掉 —— + // 只转发不落库的话,大屏刷新 / 重启(或后连上来)就再也看不到当前日程了。 + case realtime.CmdSchedule: + if err := s.saveSchedule(r.Context(), schedule, actorID); err != nil { + return nil, err + } } // 审计沿用同一条 action:现场按 action=display.command 检索的既有查询不受影响, // after 里带了这次的具体 action、公告正文、定向目标与命中数。 diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 36978bb..86004d7 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -3,6 +3,7 @@ package httpapi import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "net/http" @@ -373,6 +374,7 @@ func (s *server) writeSessionHours(ctx context.Context, hours int) error { // 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder, // 而 PUT /api/settings 是 admin-only,公告的持久化只能走指令这一侧。 +// 赛事日程侧边栏走的是同一条路(见下面的 saveSchedule)。 const ( announceTextKey = "display.announce_text" announceSecondsKey = "display.announce_seconds" @@ -384,17 +386,19 @@ func (s *server) saveAnnouncement(ctx context.Context, text string, seconds int, if seconds <= 0 { seconds = defaultAnnounceSeconds } - return s.writeAnnouncement(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds)) + return s.writeSettingValues(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds)) } // clearAnnouncement 清空当前公告;全屏时长保留,供下次推送沿用。 func (s *server) clearAnnouncement(ctx context.Context, actorID int64) error { - return s.writeAnnouncement(ctx, actorID, announceTextKey, "") + return s.writeSettingValues(ctx, actorID, announceTextKey, "") } -// writeAnnouncement 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。 -// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到公告变化。 -func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...string) error { +// writeSettingValues 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。 +// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到变化。 +// +// 名字里不绑具体的键:公告与赛事日程都从这条路落库,事务与 change_log 的写法只此一份。 +func (s *server) writeSettingValues(ctx context.Context, actorID int64, kv ...string) error { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return internal(err) @@ -422,6 +426,24 @@ func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...str return nil } +// ---------- 赛事日程侧边栏 ---------- + +// saveSchedule 把整份日程配置落库到 display.schedule(读侧见 dto.ParseScheduleValue, +// 线上形状见 dto.ScheduleConfig)。 +// +// 整份替换:服务端不做局部合并 —— 客户端手上就有整份配置,合并只会让两边不一致。 +// 校验与清洗在 scheduleFromPayload 里做完,这里只管序列化与落库。 +// +// ⚠️ 每次保存都会往 change_log 落**整份设置快照**、并让全场大屏做一次全量 resync, +// 所以这是**低频**动作(开赛前配一次、现场偶尔勾一下「已完成」),别拿它做高频轮询。 +func (s *server) saveSchedule(ctx context.Context, cfg dto.ScheduleConfig, actorID int64) error { + raw, err := json.Marshal(cfg) + if err != nil { + return internal(err) + } + return s.writeSettingValues(ctx, actorID, dto.SettingKeySchedule, string(raw)) +} + // ---------- standings ---------- func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) { diff --git a/server/internal/httpapi/system_test.go b/server/internal/httpapi/system_test.go index 3b57816..9d63e57 100644 --- a/server/internal/httpapi/system_test.go +++ b/server/internal/httpapi/system_test.go @@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) { assertKeys(t, "settings.event", asMap(t, settings["event"]), []string{"name", "subtitle", "host", "logoPath", "primaryColor"}) assertKeys(t, "settings.display", asMap(t, settings["display"]), - []string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"}) + []string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource", "schedule"}) assertKeys(t, "settings.standings", asMap(t, settings["standings"]), []string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"}) diff --git a/server/internal/realtime/hub_test.go b/server/internal/realtime/hub_test.go index 142c7a5..a6b927d 100644 --- a/server/internal/realtime/hub_test.go +++ b/server/internal/realtime/hub_test.go @@ -986,6 +986,87 @@ func TestSendCommandTargetsRejectsUnknownAction(t *testing.T) { } } +// 赛事日程(schedule)是与公告同路的整份配置:action 在协议清单里、payload 原样落到线上。 +// 只把 action 加进白名单而漏了 payload 的形状,大屏就会收到一个空的侧边栏。 +func TestSendCommandScheduleCarriesWholeConfig(t *testing.T) { + if !ValidDisplayAction(CmdSchedule) { + t.Fatalf("%s 应在协议清单里", CmdSchedule) + } + env := newTestEnv(t) + a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"}) + b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"}) + a.recvEvent(EventSnapshot) + b.recvEvent(EventSnapshot) + + payload := map[string]any{ + "items": []map[string]any{{"time": "08:30", "title": "开幕式", "done": true}}, + "visible": true, + "side": "left", + "widthPercent": float64(22), + "showNote": true, + "instanceOverrides": map[string]any{"screen-b": "off"}, + } + cmd, err := NewDisplayCommand(CmdSchedule, payload) + if err != nil { + t.Fatalf("构造 schedule: %v", err) + } + matched, err := env.hub.SendCommandToInstances(nil, cmd) + if err != nil { + t.Fatalf("广播日程: %v", err) + } + if matched != 2 { + t.Fatalf("广播应命中 2 个在线实例,得到 %d", matched) + } + for _, c := range []*wsClient{a, b} { + got := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand)) + if got.Action != CmdSchedule { + t.Fatalf("实例应收到 schedule,得到 %s", got.Action) + } + // 大屏只认 payload 里的键:整份配置(含单屏覆盖)必须逐字透传,服务端不做局部合并。 + var round map[string]any + if err := json.Unmarshal(got.Payload, &round); err != nil { + t.Fatalf("解析 payload: %v (%s)", err, got.Payload) + } + if round["side"] != "left" || round["visible"] != true || round["widthPercent"] != float64(22) { + t.Fatalf("配置应原样透传,得到 %+v", round) + } + items := round["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["title"] != "开幕式" { + t.Fatalf("条目应原样透传,得到 %+v", items) + } + if overrides := round["instanceOverrides"].(map[string]any); overrides["screen-b"] != "off" { + t.Fatalf("单屏覆盖应原样透传,得到 %+v", overrides) + } + } +} + +// schedule 也要走定向通道(与公告同一套 targets 语义)。 +func TestSendCommandScheduleTargetsSingleInstance(t *testing.T) { + env := newTestEnv(t) + a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"}) + b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"}) + a.recvEvent(EventSnapshot) + b.recvEvent(EventSnapshot) + + cmd, err := NewDisplayCommand(CmdSchedule, map[string]any{"visible": false, "items": []any{}}) + if err != nil { + t.Fatalf("构造 schedule: %v", err) + } + matched, err := env.hub.SendCommandToInstances([]string{"screen-a"}, cmd) + if err != nil { + t.Fatalf("定向日程: %v", err) + } + if matched != 1 { + t.Fatalf("命中实例数应为 1,得到 %d", matched) + } + if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdSchedule { + t.Fatalf("被点名的实例应收到 schedule,得到 %s", got.Action) + } + if ev, _, ok := b.recvWithin(200 * time.Millisecond); ok { + t.Fatalf("未被点名的实例不该收到消息,得到 %s", ev) + } +} + // 屏号上报:Console 窗口带 screen,局域网浏览器不带(空串),两者都要能查出来。 func TestDisplayInstancesReportScreen(t *testing.T) { env := newTestEnv(t) diff --git a/server/internal/realtime/protocol.go b/server/internal/realtime/protocol.go index 077ca58..204a808 100644 --- a/server/internal/realtime/protocol.go +++ b/server/internal/realtime/protocol.go @@ -64,6 +64,9 @@ const ( CmdHideDetail = "hide-detail" // CmdAnnounceClear 清除当前公告:服务端同时清设置里的持久值并广播给大屏。 CmdAnnounceClear = "announce_clear" + // CmdSchedule 整份替换「赛事日程侧边栏」配置(payload 形状见 dto.ScheduleConfig): + // 服务端校验后一次请求里既转发给大屏、也落库到 settings,刷新 / 重启不丢(与公告同路)。 + CmdSchedule = "schedule" ) var displayActions = map[string]bool{ @@ -75,6 +78,7 @@ var displayActions = map[string]bool{ CmdAnnounceClear: true, CmdShowDetail: true, CmdHideDetail: true, + CmdSchedule: true, } // ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。 diff --git a/server/internal/realtime/snapshot_test.go b/server/internal/realtime/snapshot_test.go index 3956c96..25af8f5 100644 --- a/server/internal/realtime/snapshot_test.go +++ b/server/internal/realtime/snapshot_test.go @@ -420,6 +420,42 @@ func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) { } } +// 赛事日程侧边栏随 settings 下发(不用改快照:display.* 不是 admin-only 字段)。 +// 库里那个值被改坏时快照必须照常构建、schedule 当空 —— 一条脏设置不能让大屏连不上。 +func TestSnapshotScheduleToleratesBrokenValue(t *testing.T) { + env := newTestEnv(t) + db := env.db + + mustExec(t, db, `INSERT INTO settings(key, value, updated_at) VALUES('display.schedule', ?, datetime('now'))`, + `{"items":[{"time":"08:30","title":"开幕式","done":true}],`+ + `"visible":true,"side":"left","widthPercent":22,"showNote":true}`) + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + got := snap.Settings.Display.Schedule + if got == nil || !got.Visible || got.Side != dto.ScheduleSideLeft || len(got.Items) != 1 { + t.Fatalf("日程应随快照下发: %+v", got) + } + // 线上 JSON 用大屏解析的键名(前端与 REST 同一份形状)。 + raw, err := json.Marshal(snap.Settings) + if err != nil { + t.Fatalf("序列化 settings: %v", err) + } + if !strings.Contains(string(raw), `"schedule":{"items":[{"time":"08:30","title":"开幕式","done":true}]`) { + t.Fatalf("快照里日程的形状不对: %s", raw) + } + + mustExec(t, db, `UPDATE settings SET value = '{坏 JSON' WHERE key = 'display.schedule'`) + snap, err = env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("坏数据下快照仍应构建成功: %v", err) + } + if snap.Settings.Display.Schedule != nil { + t.Fatalf("坏数据应读成 null,得到 %+v", snap.Settings.Display.Schedule) + } +} + func mustExec(t *testing.T, db *sql.DB, q string, args ...any) { t.Helper() if _, err := db.Exec(q, args...); err != nil { diff --git a/web/src/admin/pages/DisplayPage.vue b/web/src/admin/pages/DisplayPage.vue index c13fccd..d2bfcb5 100644 --- a/web/src/admin/pages/DisplayPage.vue +++ b/web/src/admin/pages/DisplayPage.vue @@ -1,6 +1,13 @@ + + + + diff --git a/web/src/display/guards.ts b/web/src/display/guards.ts index f08fde2..fec9e50 100644 --- a/web/src/display/guards.ts +++ b/web/src/display/guards.ts @@ -24,6 +24,9 @@ import type { ResultMode, ResultStatus, ResultType, + ScheduleItem, + ScheduleOverride, + ScheduleSidebarConfig, ScoreChannel, SnapshotMessage, StandingRow, @@ -31,6 +34,7 @@ import type { TeamBreakdown, } from '@/shared' import type { LiveEventSource } from './live' +import { MAX_SCHEDULE_ITEMS, clampSidebarWidthPercent } from './schedulePanel' /** * `display.rotateSeconds` 缺配置时的兜底秒数。 @@ -464,6 +468,92 @@ export function parseLiveEventIds(value: unknown): number[] { return ids } +/** 服务端写库前的字段上限(按**字符**),大屏这边原样夹一遍,坏数据不上屏。 */ +const SCHEDULE_TIME_MAX_CHARS = 20 +const SCHEDULE_TITLE_MAX_CHARS = 40 +const SCHEDULE_NOTE_MAX_CHARS = 60 +/** 单屏覆盖表的条目数上限与键长度上限,与服务端一致。 */ +const SCHEDULE_OVERRIDE_MAX = 50 +const SCHEDULE_OVERRIDE_KEY_MAX_CHARS = 120 + +const SCHEDULE_SIDES = ['left', 'right'] as const satisfies readonly ScheduleSidebarConfig['side'][] + +/** 控制字符:手填日程常常是从聊天记录里粘来的,换行 / 制表符会撑破单行排版。 */ +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g + +/** 清洗 + 夹长:控制字符(手填文本可能是从别处粘来的)一律去掉,首尾空白去掉,超长按服务端口径截断。 */ +function scheduleText(value: unknown, maxChars: number): string { + const raw = str(value).replace(CONTROL_CHARS, '').trim() + const chars = Array.from(raw) + if (chars.length <= maxChars) return raw + return `${chars.slice(0, maxChars - 1).join('')}…` +} + +/** + * 赛事日程侧边栏的容错解析。 + * + * 口径(大屏无人值守:一条脏数据不能把整页渲染炸掉): + * - `schedule` 这个键**不存在**(老服务端 / 旧缓存)或**形状坏掉**(不是对象)→ 返回 null = 没有日程; + * - 对象内部逐字段兜底:坏条目丢弃、越界值按服务端口径夹一下(宽度 15–40、条目 ≤ 40 条); + * - 时间不做格式校验(定稿口径:`08:30` / `上午` / `待定` 都是合法手填值)。 + * + * 允许传入 JSON **字符串**:settings 表里存的是文本,万一哪条路径把没解析的原始值递下来, + * 这里也要能读(与 `display.announce_text` 那类扁平键同一个理由)。 + */ +export function parseScheduleConfig(value: unknown): ScheduleSidebarConfig | null { + const source = typeof value === 'string' ? parseJsonOrNull(value) : value + if (!isRecord(source)) return null + + const items: ScheduleItem[] = [] + for (const raw of list(source.items)) { + if (items.length >= MAX_SCHEDULE_ITEMS) break + if (!isRecord(raw)) continue + const time = scheduleText(raw.time, SCHEDULE_TIME_MAX_CHARS) + const title = scheduleText(raw.title, SCHEDULE_TITLE_MAX_CHARS) + // 时间与标题都是空的条目没有任何可显示的信息,丢掉(否则轴上会多出一个空节点)。 + if (time === '' && title === '') continue + const note = scheduleText(raw.note, SCHEDULE_NOTE_MAX_CHARS) + const item: ScheduleItem = { time, title } + if (note !== '') item.note = note + // 只有显式 true 才算已完成:坏值一律当未完成(宁可显示成「还没到」,不能把没办的说成办完了)。 + if (raw.done === true) item.done = true + items.push(item) + } + + return { + items, + visible: source.visible === true, + side: oneOf(source.side, SCHEDULE_SIDES, 'right'), + widthPercent: clampSidebarWidthPercent(source.widthPercent), + showNote: source.showNote !== false, + instanceOverrides: parseScheduleOverrides(source.instanceOverrides), + } +} + +function parseJsonOrNull(value: string): unknown { + try { + return JSON.parse(value) as unknown + } catch { + return null + } +} + +function parseScheduleOverrides(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined + const overrides: Record = {} + let count = 0 + for (const [key, raw] of Object.entries(value)) { + if (count >= SCHEDULE_OVERRIDE_MAX) break + // 只认三态里的 on/off:认不出的值(含 inherit)不需要落表,缺省就是 inherit。 + if (raw !== 'on' && raw !== 'off') continue + const id = key.trim() + if (id === '' || Array.from(id).length > SCHEDULE_OVERRIDE_KEY_MAX_CHARS) continue + overrides[id] = raw + count++ + } + return count === 0 ? undefined : overrides +} + export function parseSettings(source: unknown): DisplaySettings { const rec = record(source) const showIndividualBoard = bool( @@ -485,6 +575,9 @@ export function parseSettings(source: unknown): DisplaySettings { LIVE_SOURCES, 'auto', ) + // 赛事日程侧边栏:整份配置(条目 + 开关 + 左右 / 宽度 + 单屏覆盖)由服务端随设置下发, + // 这里**只做容错收窄**——坏形状 → null = 没有日程,绝不让它把整页设置解析带崩。 + const schedule = parseScheduleConfig(settingValue(rec, 'display.schedule', 'display.schedule')) return { event: { name: str(settingValue(rec, 'event.name', 'event.name')), @@ -508,6 +601,7 @@ export function parseSettings(source: unknown): DisplaySettings { fontBody: fontBody === '' ? null : fontBody, liveEvents, liveSource, + schedule, }, standings: { individualToTeamWeight: num( diff --git a/web/src/display/mock.ts b/web/src/display/mock.ts index ed5abb3..6724e65 100644 --- a/web/src/display/mock.ts +++ b/web/src/display/mock.ts @@ -3,6 +3,7 @@ import type { Event, EventResultGroup, EventResultRow, + ScheduleItem, SnapshotMessage, StandingRow, Team, @@ -266,6 +267,32 @@ function buildAthletes(): AthleteStanding[] { }) } +/** + * 示例日程(赛事日程侧边栏)。 + * + * 刻意做得「难排」一些,这份数据就是排版与动效的验收样本: + * - 13 条(12 条以上才会触发围绕当前项开窗); + * - 第 7 条是**当前项**(前 6 条已完成、后 6 条未开始),三态一次全看到; + * - 含一条 29 字的超长标题与一条 33 字的超长备注(要换行 / 截断,但不许溢出、不许缩到下限以下); + * - 时间全是手填自由文本(`上午` / `待定` / `08:30` 混着来),系统不做任何解析。 + */ +const MOCK_SCHEDULE_ITEMS: ScheduleItem[] = [ + { time: '07:40', title: '各代表队签到、领取号码布', done: true }, + { time: '08:00', title: '开幕式 · 代表队入场', done: true, note: '07:30 前在主席台东侧集合' }, + { time: '08:30', title: '男子 100 米预赛', done: true, note: '三组取前两名进决赛' }, + { time: '09:00', title: '女子 100 米预赛', done: true }, + { time: '09:30', title: '男子跳远决赛(中年组 / 青年组同场,按试跳轮次统一编排)', done: true, + note: '检录在场地西侧入口,三次点名未到按弃权处理,成绩以电子计时为准' }, + { time: '10:10', title: '女子 4×100 米接力预赛', done: true }, + { time: '上午', title: '拔河 · 小组循环赛', note: '每队 10 人,三局两胜' }, + { time: '11:30', title: '男子 1500 米决赛' }, + { time: '13:30', title: '趣味项目 · 两人三足' }, + { time: '14:30', title: '女子 4×100 米接力决赛' }, + { time: '15:20', title: '混合团体 · 拔河决赛' }, + { time: '待定', title: '颁奖仪式 · 单位团体总分前六名' }, + { time: '16:40', title: '闭幕式', note: '如遇雨天顺延,以现场广播为准' }, +] + /** * 示例配置。单列出来是因为它比共享契约多「实时赛况」两个键(后端未就绪时也要能演示): * 直接写在 SnapshotMessage 字面量里会被多余属性检查拦下。 @@ -289,6 +316,15 @@ const MOCK_SETTINGS: DisplaySettings = { // 示例数据里有 3 个已完成项目,取前两个演示双栏分栏。 liveEvents: [1, 2], liveSource: 'manual', + // 赛事日程侧边栏:默认右侧 22%,备注上屏。示例数据里直接开着, + // 就是为了让「排版 / 三态 / 开窗 / 毛玻璃」在没有后端时也能被看到与验收。 + schedule: { + items: MOCK_SCHEDULE_ITEMS, + visible: true, + side: 'right', + widthPercent: 22, + showNote: true, + }, }, standings: { individualToTeamWeight: 1, diff --git a/web/src/display/schedulePanel.ts b/web/src/display/schedulePanel.ts new file mode 100644 index 0000000..36e3bba --- /dev/null +++ b/web/src/display/schedulePanel.ts @@ -0,0 +1,509 @@ +/** + * 大屏「赛事日程侧边栏」的纯函数层:开窗、三态、进度、字号与换行决策、单屏覆盖。 + * + * 这里一行 DOM 都没有(组件在 components/ScheduleSidebar.vue),全部是可直接单测的算术。 + * 拆开的原因:字号 / 换行 / 开窗是这套功能里唯一「算错了现场就看得出来」的部分 + * (字太小、溢出容器、当前项被挤出屏幕),必须能脱离浏览器验。 + * + * ⚠️ 双重缩放的坑(本文件所有 px 都是「舞台设计坐标」): + * 大屏的 `.stage` 是固定 1920×1080 的画布,外面套一层 `transform: scale()` 等比缩放到实际屏幕。 + * 所以侧边栏里的所有尺寸都必须是**设计像素**:4K 上舞台被放大 2 倍,40 设计 px 自然变成 80 设备 px。 + * 若拿 `window.innerWidth` / `vh` 去算字号,4K 会先算出 2 倍的字号、再被舞台放大 2 倍 = 4 倍, + * 这正是「别双重缩放」的意思。本文件的几何全部相对 `STAGE_CONTENT_*` 常量(见下),与视口无关。 + */ + +import type { ScheduleItem, ScheduleSidebarConfig, ScheduleOverride } from '@/shared' +import { MAX_ROW_FONT_PX, MIN_ROW_FONT_PX, fitRowFontPx, textEm } from './fitFont' + +/** + * 舞台内容盒(设计 px)。 + * + * 侧边栏挂在 `.stage` 里、`position:absolute; inset:0`。`.stage` 带 `transform: scale()`, + * 实测(真机量过:容器铺满 1920×1080)它同时是绝对定位后代的包含块,所以面板是**整屏满高**的, + * 与明细覆盖层一致。这里的 1824×1000 是**按舞台内边距盒算的保守预算**: + * 面板实际比它更大,布局按小一点的盒子算,最多浪费几十像素,绝不会溢出。 + * 改 App.vue 的 `.stage` padding 时要同步这里,否则面板会顶出边界。 + */ +export const STAGE_CONTENT_WIDTH_PX = 1824 +export const STAGE_CONTENT_HEIGHT_PX = 1000 + +/** 面板内边距。与组件 CSS 里的 `var(--sb-pad-*)` 同源(由组件按这里的常量注入)。 */ +export const SIDEBAR_PADDING_X_PX = 30 +export const SIDEBAR_PADDING_Y_PX = 30 + +/** 竖向时间轴一列的宽度(节点 + 轴),以及它与正文之间的间距。 */ +export const SIDEBAR_AXIS_COLUMN_PX = 44 +export const SIDEBAR_AXIS_GAP_PX = 18 + +/** + * 表头(标题 + `已完成 x / 共 y` + 进度条)高度。 + * + * 80px 是组件 CSS 实测(30px 字号一行 + 14px 间距 + 6px 进度条 + 14px 下边框), + * 再留 8px 余量:这几行 CSS 一改就要同步这里,不然布局会算得比真实空间大。 + */ +export const SIDEBAR_HEADER_PX = 88 + +/** 「上方 / 下方还有 N 项」提示各占一行的高度。 */ +export const SIDEBAR_HINT_PX = 32 + +/** 列表容器自己的上下外边距。 */ +export const SIDEBAR_LIST_MARGIN_PX = 18 + +/** 宽度百分比的默认值与上下限,与服务端口径一致(服务端写库前夹到 15–40)。 */ +export const DEFAULT_SIDEBAR_WIDTH_PERCENT = 22 +export const MIN_SIDEBAR_WIDTH_PERCENT = 15 +export const MAX_SIDEBAR_WIDTH_PERCENT = 40 + +/** 条目数上限,与服务端校验一致;超出的一律丢弃(越界值按服务端口径处理)。 */ +export const MAX_SCHEDULE_ITEMS = 40 + +/** 副文本(时间 / 备注)字号 = 标题字号 × 这个比例,且不低于 MIN_NOTE_FONT_PX。 */ +export const NOTE_FONT_RATIO = 0.8 + +/** 副文本硬下限:与标题下限同一个理由——10 米外看不清就等于没写。 */ +export const MIN_NOTE_FONT_PX = MIN_ROW_FONT_PX + +/** 标题最多排几行;再多就按显示宽度截断补省略号(绝不靠继续缩字号硬塞)。 */ +export const MAX_TITLE_LINES = 3 + +/** 备注最多排几行。 */ +export const MAX_NOTE_LINES = 2 + +/** 行高比例(相对各自字号),与组件 CSS 的 line-height 一致。 */ +export const TITLE_LINE_HEIGHT = 1.25 +export const NOTE_LINE_HEIGHT = 1.3 + +/** 一条目上下留白(相对标题字号)。 */ +export const ROW_PADDING_FACTOR = 0.5 + +/** 高度安全系数:估算墨迹与真实字形的误差、以及亚像素取整都从这里让位。 */ +const HEIGHT_SAFETY = 0.94 + +/** 字号固定点迭代的最大轮数(字号 ↔ 行数互相依赖,见 forCount)。 */ +const MAX_FONT_ROUNDS = 4 + +/** + * 「字号过关」的起点:算出来的字号**高于**硬下限才算过关,否则宁可少显示一条再看。 + * + * 这就是定稿口径「宁可行数少也不缩到看不清」在数字上的样子:16px 是「还能看」的底线, + * 正贴在底线上时,少排一条换来的那几 px 更值。想让现场尽量多看几条(密度优先), + * 把 scheduleLayout 里的 `>= COMFORTABLE_FONT_PX` 改成 `> MIN_ROW_FONT_PX` 即可。 + */ +export const COMFORTABLE_FONT_PX = MIN_ROW_FONT_PX + 1 + +/** + * 一屏至少显示几条。 + * + * 与「字号不低于 16px」是一对:字被挤到下限时宁愿少显示一条换更大的字, + * 但再少也不会少于这么多条——只剩一两条的「时间轴」没有意义。 + */ +export const MIN_WINDOW_ROWS = 5 + +/** 节点的三种状态,决定轴上那一点的样式(已完成实心淡化 / 当前放大带光晕 / 未开始低对比)。 */ +export type ScheduleNodeState = 'done' | 'current' | 'todo' + +/** 开窗结果:`[start, end)` 是窗口内的下标区间,另外两个数给「上方 / 下方还有 N 项」用。 */ +export interface ScheduleWindow { + start: number + end: number + hiddenBefore: number + hiddenAfter: number +} + +export interface ScheduleProgress { + done: number + total: number +} + +/** + * 「当前项」= 第一个未勾选完成的条目(定稿口径 2:不做时间比较——时间是完全手填的自由文本, + * 人工解析「上午」「待定」这类文本一定会出错)。 + * + * 全部完成时返回 -1 = **没有当前项**:把最后一条已完成的项标成「当前」是在撒谎, + * 调用方(开窗 / 节点样式)都按 -1 处理,轴上不会出现光晕节点。 + */ +export function currentScheduleIndex(items: readonly ScheduleItem[]): number { + for (let index = 0; index < items.length; index++) { + if (items[index]?.done !== true) return index + } + return -1 +} + +/** 整体进度读数(表头 `已完成 x / 共 y`)。 */ +export function overallProgress(items: readonly ScheduleItem[]): ScheduleProgress { + let done = 0 + for (const item of items) if (item.done === true) done++ + return { done, total: items.length } +} + +/** 单条的三态。已完成优先于「当前」判定(当前项按定义不可能是已完成的)。 */ +export function scheduleNodeState( + item: ScheduleItem | undefined, + index: number, + currentIndex: number, +): ScheduleNodeState { + if (item?.done === true) return 'done' + return index === currentIndex ? 'current' : 'todo' +} + +/** + * 围绕当前项开窗(定稿口径 1:放不下时绝不缩字号,而是少显示几条)。 + * + * 规则: + * - 条目少于容量 → 全显示; + * - 有当前项 → 当前项固定在窗口中间(容量为偶数时它略偏上,前少后多各占一半); + * - 当前项靠近头尾 → 贴住边界(第一项在窗口顶部、最后一项在窗口底部),不留下空白行; + * - 没有当前项(全部完成 / 空列表)→ 贴住末尾:刚走过的那几条才是现场想看的。 + * + * `capacity` 由 scheduleLayout 按可用高度算出来,这里只负责摆放。 + */ +export function visibleScheduleWindow( + items: readonly ScheduleItem[], + currentIndex: number, + capacity: number, +): ScheduleWindow { + const total = items.length + if (total === 0) return { start: 0, end: 0, hiddenBefore: 0, hiddenAfter: 0 } + + const size = Math.min(total, Math.max(1, Math.floor(capacity))) + let start: number + if (currentIndex < 0 || currentIndex >= total) { + start = total - size + } else { + start = currentIndex - Math.floor((size - 1) / 2) + start = Math.min(Math.max(0, start), total - size) + } + const end = start + size + return { start, end, hiddenBefore: start, hiddenAfter: total - end } +} + +/** 副文本字号(时间 / 备注共用一级):主字号 × 0.8,且有硬下限。 */ +export function scheduleNoteFontPx(fontPx: number): number { + const scaled = Math.round(fontPx * NOTE_FONT_RATIO) + return Math.max(MIN_NOTE_FONT_PX, scaled) +} + +/** + * 标题在给定字号下要排几行(1..MAX_TITLE_LINES)。 + * + * 用 `textEm` 按字符类别估宽(汉字 1.00em、数字 0.70em…),取值只许偏大, + * 所以算出来「排得下」时真机上一定排得下;返回值封顶在 MAX_TITLE_LINES, + * 超出的部分由 truncateScheduleText 补省略号,**不会**回头去缩字号。 + */ +export function scheduleTitleLines(titleEm: number, columnPx: number, fontPx: number): number { + if (!(titleEm > 0) || !(columnPx > 0) || !(fontPx > 0)) return 1 + const lines = Math.ceil((titleEm * fontPx) / columnPx) + return Math.min(MAX_TITLE_LINES, Math.max(1, lines)) +} + +/** 备注要排几行;0 = 这一条不排备注(总开关关着 / 没有备注 / 估宽为 0)。 */ +export function scheduleNoteLines( + noteEm: number, + columnPx: number, + noteFontPx: number, + showNote: boolean, +): number { + if (!showNote || !(noteEm > 0) || !(columnPx > 0) || !(noteFontPx > 0)) return 0 + const lines = Math.ceil((noteEm * noteFontPx) / columnPx) + return Math.min(MAX_NOTE_LINES, Math.max(1, lines)) +} + +/** 条目之间的竖向间距(随字号走,避免大字号时挤在一起)。 */ +export function scheduleRowGapPx(fontPx: number): number { + return Math.min(28, Math.max(10, Math.round(fontPx * 0.6))) +} + +/** + * 一条目的高度(设计 px):时间一行 + 标题若干行 + 备注若干行 + 上下留白。 + * + * 与组件 CSS 一一对应(`.time` / `.note` 用 `--sb-note-font`、`.title` 用 `--sb-font`), + * 高度算错就会溢出面板,所以这里的比例都是显式常量而不是「差不多」。 + */ +export function scheduleRowHeightPx( + fontPx: number, + titleLines: number, + noteLines: number, + hasTime: boolean, +): number { + const noteFontPx = scheduleNoteFontPx(fontPx) + const titles = Math.max(1, titleLines) * TITLE_LINE_HEIGHT * fontPx + const time = hasTime ? NOTE_LINE_HEIGHT * noteFontPx : 0 + const notes = noteLines > 0 ? noteLines * NOTE_LINE_HEIGHT * noteFontPx : 0 + return Math.ceil(titles + time + notes + ROW_PADDING_FACTOR * fontPx) +} + +/** + * 标题字号(设计 px)。 + * + * 两条约束取小: + * - **高度**:`rowCount` 行等分可用高度(`rowFactor` = 一行占几个字号), + * 直接复用 `fitRowFontPx` —— 它自带 16–56px 的硬上下限,是这套「字不能过大也不能过小」的现成口径; + * - **宽度**:最长的标题在 MAX_TITLE_LINES 行内排得下所需的字号上限 + * (所以字多的条目会换来更小的字,但只会小到下限,再长就截断)。 + * + * 返回值的下限恒为 MIN_ROW_FONT_PX:宁可截断,也不缩到 10 米外看不清。 + */ +export function scheduleFontPx( + rowCount: number, + availablePx: number, + columnPx: number, + longestTitleEm: number, + rowFactor = 1.75, +): number { + const rows = Math.max(1, Math.floor(rowCount)) + const factor = Number.isFinite(rowFactor) && rowFactor > 0 ? rowFactor : 1.75 + const byHeight = fitRowFontPx(rows, availablePx * HEIGHT_SAFETY, 1 / factor, MAX_ROW_FONT_PX) + const byWidth = + longestTitleEm > 0 && columnPx > 0 + ? Math.floor((columnPx * MAX_TITLE_LINES) / longestTitleEm) + : MAX_ROW_FONT_PX + return Math.max(MIN_ROW_FONT_PX, Math.min(byHeight, byWidth, MAX_ROW_FONT_PX)) +} + +export interface ScheduleLayoutInput { + /** 列表里的条目总数(不是窗口大小)。 */ + itemCount: number + /** 面板正文可用高度(设计 px,已扣掉内边距与表头表尾)。 */ + availablePx: number + /** 一条目正文可用宽度(设计 px,已扣掉时间轴一列)。 */ + columnPx: number + /** 窗口内最长标题的估宽(em)。 */ + longestTitleEm: number + /** 窗口内最长备注的估宽(em)。 */ + longestNoteEm: number + /** 这一屏的条目里是否出现过时间文本(决定要不要给每行留一行时间)。 */ + hasTime: boolean + /** 备注总开关。 */ + showNote: boolean +} + +export interface ScheduleLayout { + /** 这一屏能放几条(= 开窗容量)。 */ + capacity: number + fontPx: number + noteFontPx: number + titleLines: number + noteLines: number + /** 一条目的高度(含上下留白,不含条目之间的间距)。 */ + rowHeightPx: number + rowGapPx: number + /** 一条目占的总高度(高度 + 间距),容量就是按它算的。 */ + rowStridePx: number +} + +/** 面板几何(设计 px):宽度百分比换算成面板宽、正文列宽与正文可用高度。 */ +export interface SidebarGeometry { + panelPx: number + columnPx: number + availablePx: number +} + +/** 宽度百分比夹到服务端认可的 15–40;坏值退回默认 22(服务端也会夹,这里只做防御)。 */ +export function clampSidebarWidthPercent(value: unknown): number { + const num = typeof value === 'number' && Number.isFinite(value) ? value : NaN + if (!Number.isFinite(num)) return DEFAULT_SIDEBAR_WIDTH_PERCENT + return Math.round( + Math.min(MAX_SIDEBAR_WIDTH_PERCENT, Math.max(MIN_SIDEBAR_WIDTH_PERCENT, num)), + ) +} + +export function sidebarGeometry(widthPercent: number): SidebarGeometry { + const panelPx = Math.round((STAGE_CONTENT_WIDTH_PX * clampSidebarWidthPercent(widthPercent)) / 100) + const columnPx = + panelPx - 2 * SIDEBAR_PADDING_X_PX - SIDEBAR_AXIS_COLUMN_PX - SIDEBAR_AXIS_GAP_PX + // 正文可用高度:扣掉上下内边距、表头、两行「还有 N 项」提示、以及列表自己的上下外边距。 + // 提示行按「两条都在」预留:窗口有没有被截断要等容量算出来才知道,先按最坏情况留。 + const availablePx = + STAGE_CONTENT_HEIGHT_PX - + 2 * SIDEBAR_PADDING_Y_PX - + SIDEBAR_HEADER_PX - + 2 * SIDEBAR_HINT_PX - + 2 * SIDEBAR_LIST_MARGIN_PX + return { panelPx, columnPx: Math.max(1, columnPx), availablePx: Math.max(1, availablePx) } +} + +/** + * 字号与行数的最终决策(组件只调它一个)。 + * + * 为什么要迭代:字号大了标题就占更多行、行更高 → 字号又得小;反过来也一样。 + * 这里从最大字号开始做固定点迭代(最多 MAX_FONT_ROUNDS 轮), + * 再在访问过的候选里取「**真的排得下**且字号最大」的那一个,最后再从最大行数往下找容量: + * 条目少 → 容量就是条目数(全显示)、字号吃满高度;条目多 → 容量变小、字号停在下限之上。 + */ +export function scheduleLayout(input: ScheduleLayoutInput): ScheduleLayout { + const itemCount = Math.max(0, Math.floor(input.itemCount)) + const availablePx = Math.max(1, input.availablePx) + const columnPx = Math.max(1, input.columnPx) + const showNote = input.showNote && input.longestNoteEm > 0 + + const forCount = (count: number): ScheduleLayout => { + const at = (fontPx: number): ScheduleLayout => { + const titleLines = scheduleTitleLines(input.longestTitleEm, columnPx, fontPx) + const noteFontPx = scheduleNoteFontPx(fontPx) + const noteLines = scheduleNoteLines(input.longestNoteEm, columnPx, noteFontPx, showNote) + const rowHeightPx = scheduleRowHeightPx(fontPx, titleLines, noteLines, input.hasTime) + const rowGapPx = scheduleRowGapPx(fontPx) + return { + capacity: count, + fontPx, + noteFontPx, + titleLines, + noteLines, + rowHeightPx, + rowGapPx, + rowStridePx: rowHeightPx + rowGapPx, + } + } + + let current = at(MAX_ROW_FONT_PX) + const seen: ScheduleLayout[] = [current] + for (let round = 0; round < MAX_FONT_ROUNDS; round++) { + const next = at( + scheduleFontPx( + count, + availablePx, + columnPx, + input.longestTitleEm, + current.rowStridePx / Math.max(1, current.fontPx), + ), + ) + if (next.fontPx === current.fontPx) break + current = next + seen.push(next) + } + + let best: ScheduleLayout | null = null + for (const candidate of seen) { + if (!layoutFits(candidate, count, availablePx)) continue + if (best === null || candidate.fontPx > best.fontPx) best = candidate + } + return best ?? current + } + + if (itemCount === 0) return forCount(0) + + // 理论上能放下的最大行数:按最小字号 + 最少行数算,再往上一档就是物理上放不下。 + const floorStride = scheduleRowHeightPx(MIN_ROW_FONT_PX, 1, showNote ? 1 : 0, input.hasTime) + const maxRows = Math.max(1, Math.floor(availablePx / floorStride)) + const upper = Math.min(itemCount, maxRows) + + const minRows = Math.min(itemCount, MIN_WINDOW_ROWS) + let widest: ScheduleLayout | null = null + for (let count = upper; count >= 1; count--) { + const candidate = forCount(count) + if (!layoutFits(candidate, count, availablePx)) continue + // 最大的可行行数:一路让到「最少显示几条」都还放不下时才用它兜底。 + if (widest === null) widest = candidate + if (candidate.fontPx >= COMFORTABLE_FONT_PX) return candidate + // 字号已经贴死在硬下限上:这时少显示一条能换来更大的字,就少显示一条 + // (定稿口径:宁可显示的行数少,也不缩到看不清),但不再少于 MIN_WINDOW_ROWS 条。 + if (count <= minRows) return candidate + } + return widest ?? forCount(1) +} + +function layoutFits(layout: ScheduleLayout, count: number, availablePx: number): boolean { + if (count <= 0) return true + return count * layout.rowHeightPx + (count - 1) * layout.rowGapPx <= availablePx +} + +/** + * 按**显示宽度**(em)截断,超出补省略号。 + * + * 与 truncateDeviceLabel 的口径不同:那边按码点个数切(设备标签是等宽的拉丁字符), + * 这里必须按估宽切——「III」和「测试」的字符数一样、占的宽度差一倍。 + * 省略号自己也占位,所以先把它从预算里扣掉,保证截断后的总宽**不会**超过 maxEm。 + */ +export function truncateScheduleText(text: string, maxEm: number): string { + if (!(maxEm > 0)) return '' + if (textEm(text) <= maxEm) return text + const budget = maxEm - textEm('…') + if (budget <= 0) return '…' + let out = '' + let used = 0 + for (const ch of text) { + const width = textEm(ch) + if (used + width > budget) break + out += ch + used += width + } + return out === '' ? '…' : `${out}…` +} + +export interface ResolvedSidebarConfig { + enabled: boolean + side: 'left' | 'right' + widthPercent: number + showNote: boolean +} + +/** + * 把单屏覆盖(inherit / on / off)套到总开关上(定稿口径 8:键是**实例 id**)。 + * + * 覆盖键认不出(不在表里)就是 inherit,与「没配过」完全一样; + * `instanceId` 为空字符串时**不做任何匹配**——空键要是能命中,一块屏的临时覆盖会误伤所有 + * 报不出 id 的屏(老服务端 / 存储不可用)。 + */ +export function resolveSidebarConfig( + config: ScheduleSidebarConfig | null | undefined, + instanceId: string, +): ResolvedSidebarConfig { + const side = config?.side === 'left' ? 'left' : 'right' + const widthPercent = clampSidebarWidthPercent(config?.widthPercent) + // 备注默认上屏(定稿口径 5):缺字段 / 坏值都当成「上屏」,只有显式 false 才关。 + const showNote = config?.showNote !== false + if (config === null || config === undefined) { + return { enabled: false, side, widthPercent, showNote } + } + + const override = scheduleOverrideFor(config.instanceOverrides, instanceId) + const enabled = + override === 'on' ? true : override === 'off' ? false : config.visible === true + return { enabled, side, widthPercent, showNote } +} + +/** 取一块屏的覆盖值;认不出就是 inherit。 */ +export function scheduleOverrideFor( + overrides: Record | undefined, + instanceId: string, +): ScheduleOverride { + if (overrides === undefined || instanceId === '') return 'inherit' + const value = overrides[instanceId] + return value === 'on' || value === 'off' ? value : 'inherit' +} + +/** + * 明细覆盖层 / 全屏公告期间自动收起(定稿口径 6)。 + * + * 只是**渲染期**的抑制,开关状态一个字节都不动:收起结束(明细关闭 / 公告退场)后原样回来。 + * 这也是侧边栏与 detail 状态机唯一的接触点——它只读 detail,绝不参与开关明细。 + */ +export function sidebarSuppressed(detailOpen: boolean, announceFullscreen: boolean): boolean { + return detailOpen || announceFullscreen +} + +/** + * 配置指纹:指令(热开关)与设置(持久化)两条来源的同源去重。 + * + * 服务端保存日程时会「先转发指令、再落库」,落库后大屏还会收到一份一模一样的设置值; + * 不去重就会让面板重建一次(入场动效重放、看起来像闪了一下)。 + * 指纹只取影响画面的字段,且顺序稳定(覆盖键排序),所以两条来源能可靠地判等。 + */ +export function scheduleSignature(config: ScheduleSidebarConfig | null | undefined): string { + if (config === null || config === undefined) return '' + const overrides = Object.entries(config.instanceOverrides ?? {}) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, value]) => `${key}=${value}`) + .join(',') + const items = config.items + .map((item) => `${item.time}\u0001${item.title}\u0001${item.note ?? ''}\u0001${item.done === true ? 1 : 0}`) + .join('\u0002') + return [ + config.visible === true ? 1 : 0, + config.side, + clampSidebarWidthPercent(config.widthPercent), + config.showNote === false ? 0 : 1, + overrides, + items, + ].join('\u0003') +} diff --git a/web/src/display/tests/schedule-guards.test.ts b/web/src/display/tests/schedule-guards.test.ts new file mode 100644 index 0000000..75f6138 --- /dev/null +++ b/web/src/display/tests/schedule-guards.test.ts @@ -0,0 +1,116 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { parseScheduleConfig, parseSettings } from '../guards' + +/** + * 容错解析:大屏是无人值守的,`display.schedule` 这一键坏了**只许坏这一键**。 + * 这里逐条钉住「缺 / 坏 → null(= 没有日程)」「越界值按服务端口径夹」「坏条目丢弃」。 + */ + +test('缺字段 / 坏形状一律 null,绝不抛错', () => { + for (const bad of [undefined, null, 0, '', '不是 JSON', [], true, () => 1]) { + assert.equal(parseScheduleConfig(bad), null, `${String(bad)} 应该解析成 null`) + } +}) + +test('对象形状:缺字段按默认值补齐(关着 + 右侧 22% + 备注上屏)', () => { + assert.deepEqual(parseScheduleConfig({}), { + items: [], + visible: false, + side: 'right', + widthPercent: 22, + showNote: true, + instanceOverrides: undefined, + }) +}) + +test('条目:坏形状丢弃、控制字符清洗、超长按服务端口径截断', () => { + const parsed = parseScheduleConfig({ + visible: true, + items: [ + { time: '08:30', title: '男子 100 米预赛' }, + 'not-an-object', + { time: '', title: '' }, + { time: ' ', title: ' ' }, + { time: '09:00', title: 'a\nb\tc' }, + { time: 'x'.repeat(50), title: 't'.repeat(50), note: 'n'.repeat(80) }, + { time: '上午', title: '拔河', done: true }, + { time: '待定', title: '颁奖', done: 'yes' }, + ], + }) + assert.ok(parsed !== null) + const items = parsed.items + assert.equal(items.length, 5, '3 个坏条目被丢掉') + assert.deepEqual(items[0], { time: '08:30', title: '男子 100 米预赛' }) + assert.equal(items[1]?.title, 'abc', '控制字符清洗掉') + assert.equal(items[2]?.time.length, 20, 'time 上限 20 字符') + assert.equal(items[2]?.title.length, 40, 'title 上限 40 字符') + assert.equal(items[2]?.note?.length, 60, 'note 上限 60 字符') + assert.equal(items[3]?.done, true) + assert.equal(items[4]?.done, undefined, 'done 只认布尔 true,坏值当未完成') + assert.equal(items[0]?.note, undefined, '空备注不落字段') +}) + +test('条目数上限 40,超出的丢弃', () => { + const many = Array.from({ length: 60 }, (_, index) => ({ time: `${index}`, title: `第 ${index} 项` })) + const parsed = parseScheduleConfig({ visible: true, items: many }) + assert.equal(parsed?.items.length, 40) +}) + +test('开关与越界值:宽度夹到 15–40,side 只认 left/right', () => { + assert.equal(parseScheduleConfig({ widthPercent: 5, side: 'left' })?.widthPercent, 15) + assert.equal(parseScheduleConfig({ widthPercent: 99 })?.widthPercent, 40) + assert.equal(parseScheduleConfig({ widthPercent: '很大' })?.widthPercent, 22) + assert.equal(parseScheduleConfig({ widthPercent: 26.6 })?.widthPercent, 27) + assert.equal(parseScheduleConfig({ side: 'top' })?.side, 'right') + assert.equal(parseScheduleConfig({ side: 'left' })?.side, 'left') + // visible 只认布尔 true:`"true"` 这种字符串不算开着。 + assert.equal(parseScheduleConfig({ visible: 'true' })?.visible, false) + assert.equal(parseScheduleConfig({ showNote: false })?.showNote, false) +}) + +test('单屏覆盖:只留 on/off,键做清洗与长度限制', () => { + const parsed = parseScheduleConfig({ + instanceOverrides: { + screenA: 'on', + screenB: 'off', + screenC: 'inherit', + screenD: 'ON', + ' screenE ': 'off', + '': 'on', + [('k'.repeat(200))]: 'on', + }, + }) + assert.deepEqual(parsed?.instanceOverrides, { screenA: 'on', screenB: 'off', screenE: 'off' }) + assert.equal(parseScheduleConfig({ instanceOverrides: {} })?.instanceOverrides, undefined) + assert.equal(parseScheduleConfig({ instanceOverrides: 'nope' })?.instanceOverrides, undefined) +}) + +test('允许 JSON 字符串(settings 表里存的是文本)', () => { + const json = JSON.stringify({ items: [{ time: '08:00', title: '开幕式' }], visible: true }) + const parsed = parseScheduleConfig(json) + assert.equal(parsed?.visible, true) + assert.equal(parsed?.items[0]?.title, '开幕式') +}) + +test('parseSettings:schedule 坏掉不影响同一包里别的设置', () => { + const settings = parseSettings({ + display: { title: '2026 职工运动会', rotateSeconds: 20, schedule: '坏值' }, + standings: { showIndividualBoard: false }, + }) + assert.equal(settings.display.schedule, null) + assert.equal(settings.display.title, '2026 职工运动会', '同一包里的标题照旧解析出来') + assert.equal(settings.display.rotateSeconds, 20) + assert.equal(settings.standings.showIndividualBoard, false) + + const good = parseSettings({ + display: { title: 'x', schedule: { items: [{ time: '08:00', title: '开幕式' }], visible: true } }, + }) + assert.equal(good.display.schedule?.items.length, 1) + assert.equal(good.display.schedule?.visible, true) +}) + +test('parseSettings:老服务端不带 schedule 时是 null(= 大屏上没有这个功能)', () => { + const settings = parseSettings({ display: { title: 'x' } }) + assert.equal(settings.display.schedule, null) +}) diff --git a/web/src/display/tests/schedule-panel.test.ts b/web/src/display/tests/schedule-panel.test.ts new file mode 100644 index 0000000..bc74c7e --- /dev/null +++ b/web/src/display/tests/schedule-panel.test.ts @@ -0,0 +1,386 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import type { ScheduleItem, ScheduleSidebarConfig } from '@/shared' +import { MAX_ROW_FONT_PX, MIN_ROW_FONT_PX, textEm } from '../fitFont' +import { + MAX_TITLE_LINES, + STAGE_CONTENT_WIDTH_PX, + clampSidebarWidthPercent, + currentScheduleIndex, + overallProgress, + resolveSidebarConfig, + scheduleFontPx, + scheduleLayout, + scheduleNodeState, + scheduleNoteLines, + scheduleRowHeightPx, + scheduleSignature, + scheduleTitleLines, + sidebarGeometry, + sidebarSuppressed, + truncateScheduleText, + visibleScheduleWindow, +} from '../schedulePanel' + +function item(time: string, title: string, extra: Partial = {}): ScheduleItem { + return { time, title, ...extra } +} + +/** 12 条:前 6 条完成、第 7 条是当前项 —— 与示例数据同一形状,开窗用例都拿它当基准。 */ +const TWELVE: ScheduleItem[] = Array.from({ length: 12 }, (_, index) => + item(`${8 + index}:00`, `第 ${index + 1} 项`, index < 6 ? { done: true } : {}), +) + +test('当前项 = 第一个未勾选的条目', () => { + assert.equal(currentScheduleIndex(TWELVE), 6) + assert.equal(currentScheduleIndex(TWELVE.slice(0, 6)), -1, '全部完成时没有当前项') + assert.equal(currentScheduleIndex([]), -1) + assert.equal(currentScheduleIndex([item('08:00', '甲', { done: false })]), 0) +}) + +test('整体进度读数', () => { + assert.deepEqual(overallProgress(TWELVE), { done: 6, total: 12 }) + assert.deepEqual(overallProgress([]), { done: 0, total: 0 }) +}) + +test('三态:已完成 / 当前 / 未开始', () => { + const current = currentScheduleIndex(TWELVE) + assert.equal(scheduleNodeState(TWELVE[0], 0, current), 'done') + assert.equal(scheduleNodeState(TWELVE[5], 5, current), 'done') + assert.equal(scheduleNodeState(TWELVE[6], 6, current), 'current') + assert.equal(scheduleNodeState(TWELVE[7], 7, current), 'todo') + // 全部完成:没有任何节点是「当前」,最后一条也是已完成(不能把它当成当前项)。 + assert.equal(scheduleNodeState(TWELVE[5], 5, -1), 'done') + assert.equal(scheduleNodeState(item('08:00', '甲'), 0, -1), 'todo') +}) + +test('开窗:条目少于容量时全部显示', () => { + const win = visibleScheduleWindow(TWELVE.slice(0, 4), 2, 8) + assert.deepEqual(win, { start: 0, end: 4, hiddenBefore: 0, hiddenAfter: 0 }) +}) + +test('开窗:围绕当前项前后尽量对称', () => { + const win = visibleScheduleWindow(TWELVE, 6, 7) + assert.deepEqual(win, { start: 3, end: 10, hiddenBefore: 3, hiddenAfter: 2 }) + assert.ok(win.start <= 6 && 6 < win.end, '当前项必须在窗口里') +}) + +test('开窗:当前项在第一项 / 最后一项时贴边,不留空行', () => { + assert.deepEqual(visibleScheduleWindow(TWELVE, 0, 5), { + start: 0, + end: 5, + hiddenBefore: 0, + hiddenAfter: 7, + }) + assert.deepEqual(visibleScheduleWindow(TWELVE, 11, 5), { + start: 7, + end: 12, + hiddenBefore: 7, + hiddenAfter: 0, + }) +}) + +test('开窗:全部完成(没有当前项)时贴住末尾', () => { + const done = TWELVE.map((one) => ({ ...one, done: true })) + const win = visibleScheduleWindow(done, currentScheduleIndex(done), 4) + assert.deepEqual(win, { start: 8, end: 12, hiddenBefore: 8, hiddenAfter: 0 }) +}) + +test('开窗:空列表 / 容量为 0 都不崩,且窗口永不含空行', () => { + assert.deepEqual(visibleScheduleWindow([], 0, 5), { + start: 0, + end: 0, + hiddenBefore: 0, + hiddenAfter: 0, + }) + const win = visibleScheduleWindow(TWELVE, 6, 0) + assert.equal(win.end - win.start, 1, '容量退化时至少显示 1 条,不能是空窗') + for (let capacity = 1; capacity <= 14; capacity++) { + const current = currentScheduleIndex(TWELVE) + const each = visibleScheduleWindow(TWELVE, current, capacity) + assert.ok(each.start <= current && current < each.end, `容量 ${capacity} 时当前项掉出窗口`) + assert.ok(each.end - each.start <= Math.min(12, capacity)) + } +}) + +test('字号:永远落在 16–56px 硬上下限内', () => { + const cases: [number, number, number, number][] = [ + [1, 900, 400, 4], + [40, 900, 200, 60], + [12, 900, 279, 29], + [3, 100, 60, 80], + [1, 100000, 100000, 1], + [8, 900, 279, 0], + ] + for (const [rows, available, column, em] of cases) { + const font = scheduleFontPx(rows, available, column, em) + assert.ok(font >= MIN_ROW_FONT_PX, `${rows}/${available}/${column}/${em} → ${font} 低于下限`) + assert.ok(font <= MAX_ROW_FONT_PX, `${rows}/${available}/${column}/${em} → ${font} 超过上限`) + } +}) + +test('字号:文本越长字号越小、行数越多字号越小', () => { + const short = scheduleFontPx(12, 792, 279, 8) + const long = scheduleFontPx(12, 792, 279, 40) + assert.ok(long <= short, `长文本没有收字号:${long} vs ${short}`) + assert.equal(long, Math.floor((279 * MAX_TITLE_LINES) / 40), '长文本按「最多 3 行」从宽度收上限') + + const few = scheduleFontPx(4, 792, 279, 8) + const many = scheduleFontPx(20, 792, 279, 8) + assert.ok(many < few, `行数多没有变小:${many} vs ${few}`) + assert.equal(scheduleFontPx(0, 792, 279, 8), scheduleFontPx(1, 792, 279, 8), '0 行按 1 行算') +}) + +test('标题行数:按显示宽度算,封顶 3 行', () => { + assert.equal(scheduleTitleLines(4, 279, 24), 1) + assert.equal(scheduleTitleLines(29, 279, 24), 3, '29 字在 279px 宽 / 24px 字号下要 3 行') + assert.equal(scheduleTitleLines(200, 279, 24), MAX_TITLE_LINES, '再长也只允许 3 行(改截断)') + assert.equal(scheduleTitleLines(0, 279, 24), 1) +}) + +test('备注行数:总开关关掉时一行都不排', () => { + assert.equal(scheduleNoteLines(20, 279, 20, true), 2) + assert.equal(scheduleNoteLines(20, 279, 20, false), 0) + assert.equal(scheduleNoteLines(0, 279, 20, true), 0) +}) + +/** 示例数据那种「最坏情况」的量:29 字标题 + 33 字备注。 */ +const DENSE = { + longestTitleEm: 29, + longestNoteEm: 33, + hasTime: true, + showNote: true, +} + +test('布局:1080p / 22% 宽,13 条长文本时的容量、字号与实际渲染条数', () => { + const geo = sidebarGeometry(22) + assert.equal(geo.panelPx, Math.round((STAGE_CONTENT_WIDTH_PX * 22) / 100)) + assert.equal(geo.columnPx, 279, '面板 401px − 两侧内边距 60 − 时间轴 44 − 间距 18') + assert.equal( + geo.availablePx, + 752, + '保守预算:舞台内边距盒 1000 − 上下内边距 60 − 表头 88 − 两行提示 64 − 列表外边距 36', + ) + + const layout = scheduleLayout({ + itemCount: 13, + availablePx: geo.availablePx, + columnPx: geo.columnPx, + ...DENSE, + }) + // 排得下:条目高度之和不超过面板正文高度(少一条都不许溢出)。 + assert.ok( + layout.capacity * layout.rowHeightPx + (layout.capacity - 1) * layout.rowGapPx <= geo.availablePx, + `排不下:容量 ${layout.capacity} × ${layout.rowHeightPx}px`, + ) + assert.equal(layout.capacity, 5, '29 字标题 + 33 字备注时一屏 5 条:字号优先,宁可行数少') + assert.equal(layout.fontPx, 19, '贴着下限(16px)时少显示一条换来 19px 的字') + assert.ok(layout.fontPx >= MIN_ROW_FONT_PX && layout.fontPx <= MAX_ROW_FONT_PX) + assert.equal(layout.noteFontPx, Math.max(16, Math.round(layout.fontPx * 0.8))) + // 窗口开在当前项(第 7 条)两侧,前后各 2 条。 + assert.deepEqual(visibleScheduleWindow(new Array(13).fill(0).map(() => item('', '')), 6, layout.capacity), { + start: 4, + end: 9, + hiddenBefore: 4, + hiddenAfter: 4, + }) +}) + +test('布局:普通长度文本时 13 条显示 8 条,字号留在下限之上', () => { + const geo = sidebarGeometry(22) + const layout = scheduleLayout({ + itemCount: 13, + availablePx: geo.availablePx, + columnPx: geo.columnPx, + longestTitleEm: 8, + longestNoteEm: 10, + hasTime: true, + showNote: true, + }) + // 13 条硬塞会在 16px 上(正贴死下限),少显示几条换来 20px: + // 「宁可显示的行数少,也不缩到看不清」——这就是那条口径在数字上的样子。 + assert.equal(layout.capacity, 8) + assert.equal(layout.fontPx, 18) + assert.ok(layout.fontPx > MIN_ROW_FONT_PX, '字少时不该贴在下限上') + assert.ok( + layout.capacity * layout.rowHeightPx + (layout.capacity - 1) * layout.rowGapPx <= geo.availablePx, + ) +}) + +test('布局:极短文本时全部显示(放得下就不开窗)', () => { + const geo = sidebarGeometry(22) + const layout = scheduleLayout({ + itemCount: 6, + availablePx: geo.availablePx, + columnPx: geo.columnPx, + longestTitleEm: 4, + longestNoteEm: 0, + hasTime: true, + showNote: false, + }) + assert.equal(layout.capacity, 6) + assert.ok(layout.fontPx >= MIN_ROW_FONT_PX) +}) + +test('布局:条目越多容量越小,但字号绝不低于下限', () => { + const geo = sidebarGeometry(22) + const base = { + availablePx: geo.availablePx, + columnPx: geo.columnPx, + longestTitleEm: 29, + longestNoteEm: 33, + hasTime: true, + showNote: true, + } + const small = scheduleLayout({ ...base, itemCount: 6 }) + const large = scheduleLayout({ ...base, itemCount: 40 }) + assert.ok(small.fontPx >= large.fontPx, '条目少时字号不该更小') + assert.ok(large.capacity < 40, '40 条必须开窗') + assert.ok(large.fontPx >= MIN_ROW_FONT_PX) + assert.ok( + large.capacity * large.rowHeightPx + (large.capacity - 1) * large.rowGapPx <= geo.availablePx, + ) + assert.equal(scheduleLayout({ ...base, itemCount: 0 }).capacity, 0) +}) + +test('布局:没有备注时不排备注行,容量相应变大', () => { + const geo = sidebarGeometry(22) + const common = { + itemCount: 20, + availablePx: geo.availablePx, + columnPx: geo.columnPx, + longestTitleEm: 12, + longestNoteEm: 30, + hasTime: true, + } + const withNote = scheduleLayout({ ...common, showNote: true }) + const without = scheduleLayout({ ...common, showNote: false }) + assert.ok(without.noteLines === 0 && withNote.noteLines > 0) + assert.ok(without.capacity >= withNote.capacity) +}) + +test('行高:时间 / 备注各占一行,比例固定', () => { + const oneLine = scheduleRowHeightPx(24, 1, 0, false) + const withTime = scheduleRowHeightPx(24, 1, 0, true) + const withNote = scheduleRowHeightPx(24, 1, 1, true) + assert.ok(withTime > oneLine, '有时间时条目更高') + assert.ok(withNote > withTime, '有备注时条目更高') + // 24px 标题一行:24 × 1.25 + 上下留白 24 × 0.5 = 42 + assert.equal(oneLine, 42) +}) + +test('按显示宽度截断,而不是按字符数', () => { + assert.equal(truncateScheduleText('男子 100 米', 20), '男子 100 米') + // 6 个汉字正好 6em,放得下;8 个拉丁字母 6.8em 放不下,要截。 + assert.equal(truncateScheduleText('测试测试测试', 6), '测试测试测试') + const cut = truncateScheduleText('IIIIIIII', 6) + assert.ok(cut.endsWith('…')) + assert.ok(textEm(cut) <= 6, `截断后仍超宽:${cut} = ${textEm(cut)}em`) + assert.ok(cut.length < 8, '确实切短了') + + const long = truncateScheduleText('男子跳远决赛(中年组 / 青年组同场,按试跳轮次统一编排)', 4) + assert.ok(long.endsWith('…') && textEm(long) <= 4) + assert.equal(truncateScheduleText('任意文本', 0), '') + assert.equal(truncateScheduleText('任意文本', -1), '') + assert.equal(truncateScheduleText('', 10), '') +}) + +test('单屏覆盖三态 × 总开关:六种组合', () => { + const cases: [boolean, 'inherit' | 'on' | 'off', boolean][] = [ + [true, 'inherit', true], + [true, 'on', true], + [true, 'off', false], + [false, 'inherit', false], + [false, 'on', true], + [false, 'off', false], + ] + for (const [visible, override, expected] of cases) { + const config: ScheduleSidebarConfig = { + items: [], + visible, + side: 'right', + widthPercent: 22, + showNote: true, + instanceOverrides: { screenA: override }, + } + assert.equal( + resolveSidebarConfig(config, 'screenA').enabled, + expected, + `visible=${visible} override=${override}`, + ) + } +}) + +test('单屏覆盖:认不出的键 = 跟随总开关,空实例 id 绝不命中', () => { + const config: ScheduleSidebarConfig = { + items: [], + visible: true, + side: 'left', + widthPercent: 30, + showNote: false, + instanceOverrides: { screenA: 'off', '': 'on' }, + } + assert.equal(resolveSidebarConfig(config, 'screenB').enabled, true, '没配过的屏跟随总开关') + assert.equal(resolveSidebarConfig(config, '').enabled, true, '空 id 不许命中空键覆盖') + const resolved = resolveSidebarConfig(config, 'screenA') + assert.deepEqual( + { enabled: resolved.enabled, side: resolved.side, widthPercent: resolved.widthPercent, showNote: resolved.showNote }, + { enabled: false, side: 'left', widthPercent: 30, showNote: false }, + ) +}) + +test('没有日程配置时:关闭 + 默认位置 / 宽度 / 备注上屏', () => { + for (const empty of [null, undefined]) { + assert.deepEqual(resolveSidebarConfig(empty, 'screenA'), { + enabled: false, + side: 'right', + widthPercent: 22, + showNote: true, + }) + } +}) + +test('宽度百分比按服务端口径夹到 15–40,坏值退回 22', () => { + assert.equal(clampSidebarWidthPercent(5), 15) + assert.equal(clampSidebarWidthPercent(99), 40) + assert.equal(clampSidebarWidthPercent(22.4), 22) + assert.equal(clampSidebarWidthPercent(Number.NaN), 22) + assert.equal(clampSidebarWidthPercent(undefined), 22) + assert.equal(clampSidebarWidthPercent('30'), 22, '字符串不是数字,退回默认') +}) + +test('抑制来源:明细覆盖层 / 全屏公告', () => { + assert.equal(sidebarSuppressed(false, false), false) + assert.equal(sidebarSuppressed(true, false), true) + assert.equal(sidebarSuppressed(false, true), true) + assert.equal(sidebarSuppressed(true, true), true) +}) + +test('配置指纹:内容相同即相同(指令与持久值去重),内容变了就不同', () => { + const base: ScheduleSidebarConfig = { + items: [item('08:00', '甲', { done: true }), item('09:00', '乙', { note: '检录' })], + visible: true, + side: 'right', + widthPercent: 22, + showNote: true, + instanceOverrides: { a: 'on', b: 'off' }, + } + const copy: ScheduleSidebarConfig = JSON.parse(JSON.stringify(base)) as ScheduleSidebarConfig + assert.equal(scheduleSignature(base), scheduleSignature(copy)) + // 覆盖表顺序不影响指纹(服务端落库后键序可能变)。 + const shuffled: ScheduleSidebarConfig = { ...copy, instanceOverrides: { b: 'off', a: 'on' } } + assert.equal(scheduleSignature(base), scheduleSignature(shuffled)) + assert.notEqual(scheduleSignature(base), scheduleSignature({ ...copy, visible: false })) + assert.notEqual(scheduleSignature(base), scheduleSignature({ ...copy, showNote: false })) + assert.notEqual(scheduleSignature(base), scheduleSignature({ ...copy, widthPercent: 30 })) + assert.notEqual( + scheduleSignature(base), + scheduleSignature({ ...copy, items: [item('08:00', '甲', { done: true })] }), + ) + assert.notEqual( + scheduleSignature(base), + scheduleSignature({ ...copy, items: [item('08:00', '甲', { done: true }), item('09:00', '乙', { note: '检录2' })] }), + ) + assert.equal(scheduleSignature(null), '') + assert.equal(scheduleSignature(undefined), '') +}) diff --git a/web/src/display/tests/schedule-wiring.test.ts b/web/src/display/tests/schedule-wiring.test.ts new file mode 100644 index 0000000..68cbbc7 --- /dev/null +++ b/web/src/display/tests/schedule-wiring.test.ts @@ -0,0 +1,121 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +/** + * 接线钉子:赛事日程侧边栏的「最后一公里」。 + * + * `.vue` 与 useScoreboard.ts 跑不进 node:test(仓库里没有 jsdom), + * 纯函数全绿也只说明算术对,接没接上必须另钉一遍。这里钉的每条都对应一个真实的半成品形态: + * - 面板挂错层(挂在 `.stage` 外面 → fixed 定位在带 transform 的舞台上乱飞); + * - 组件参与了 detail 状态机(红线 3:goto 一关明细、明细一停轮播,侧边栏跟着没了); + * - 忘了 `@supports` 退化(禁用毛玻璃的浏览器上白字叠在成绩上); + * - 指令热开关写了、设置 watcher 没写(刷新就丢); + * - 或者反过来,watcher 没去重(指令与持久值各来一份 → 面板闪一下)。 + */ +const read = (path: string): string => readFileSync(new URL(path, import.meta.url), 'utf8') + +/** 去掉注释再断言:注释里写着「不许用 fixed」「不许出现 window」这类说明本身会被误判。 */ +const stripComments = (source: string): string => + source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(//g, '') + .replace(/^\s*\/\/[^\n]*$/gm, '') + +const APP = read('../App.vue') +const COMPONENT = read('../components/ScheduleSidebar.vue') +const USE_SCOREBOARD = read('../useScoreboard.ts') +const GUARDS = read('../guards.ts') +const PANEL = read('../schedulePanel.ts') +/** 去掉注释后的源码:下面凡是对「不许出现某写法」的断言都看它。 */ +const PANEL_CODE = stripComments(PANEL) +const CODE = stripComments(COMPONENT) + +test('侧边栏挂在 之后,且在 .stage 里', () => { + const overlay = APP.indexOf('') + const sidebar = APP.indexOf('') + assert.ok(overlay > 0, 'DetailOverlay 还在') + assert.ok(sidebar > overlay, '侧边栏必须挂在明细覆盖层之后(覆盖层在最上层)') + assert.equal(APP.split('').length - 1, 1, '只挂一次') + const stageIndex = APP.indexOf('class="stage"') + assert.ok(stageIndex > 0 && stageIndex < sidebar, '必须挂在 .stage 内(.stage 带 transform,外面挂会错位)') +}) + +test('组件容器是 absolute + inset:0 + z-index:50,绝不用 fixed', () => { + assert.match(COMPONENT, /\.schedule-layer\s*\{[^}]*position:\s*absolute/) + assert.match(COMPONENT, /\.schedule-layer\s*\{[^}]*inset:\s*0/) + assert.match(COMPONENT, /\.schedule-layer\s*\{[^}]*z-index:\s*50/) + assert.doesNotMatch(COMPONENT, /position:\s*fixed/, 'fixed 的包含块是 .stage,会被 transform 带偏') +}) + +test('面板几何照 DetailOverlay 参数化:左 / 右 + 宽度百分比', () => { + assert.match(COMPONENT, /\[side\.value\]:\s*'0'/, '左右由 side 决定') + assert.match(COMPONENT, /width:\s*`\$\{geometry\.value\.panelPx\}px`/) + assert.match(COMPONENT, /sidebarGeometry\(resolved\.value\.widthPercent\)/) +}) + +test('关着的时候一件 DOM 都不留', () => { + assert.match(COMPONENT, /
/) + assert.doesNotMatch(CODE, / { + assert.match(COMPONENT, /backdrop-filter:\s*blur\(/) + assert.match(COMPONENT, /@supports not \(\(backdrop-filter: blur\(1px\)\) or \(-webkit-backdrop-filter: blur\(1px\)\)\)/) + // 退化块里必须换成更不透明的深底,否则身后的榜单会穿出来把白字顶掉。 + const fallback = COMPONENT.slice(COMPONENT.indexOf('@supports not')) + assert.match(fallback, /rgba\(4, 11, 24, 0\.97\)/) +}) + +test('主题变量与减少动效', () => { + for (const token of ['var(--primary)', 'var(--primary-line)', 'var(--primary-flash)', 'var(--text)', 'var(--text-dim)']) { + assert.ok(COMPONENT.includes(token), `要用主题变量 ${token}`) + } + assert.match(COMPONENT, /@media \(prefers-reduced-motion: reduce\)/) +}) + +test('侧边栏只读 detail,绝不碰明细状态机与轮播(红线 3)', () => { + assert.match(COMPONENT, /sidebarSuppressed\(detail\.value !== null, announceFullscreen\.value\)/) + for (const forbidden of ['closeDetail', 'openDetail', 'startPageRotation', 'stopPageRotation', 'detail.value =']) { + assert.ok(!COMPONENT.includes(forbidden), `侧边栏不许碰 ${forbidden}`) + } + // useScoreboard 的 schedule 分支也不许动明细 / 页面。 + const branch = USE_SCOREBOARD.slice( + USE_SCOREBOARD.indexOf("case 'schedule'"), + USE_SCOREBOARD.indexOf("case 'show-detail'"), + ) + assert.ok(branch.length > 0, 'handleCommand 里要有 schedule 分支') + for (const forbidden of ['closeDetail', 'page.value =', 'stopPageRotation']) { + assert.ok(!branch.includes(forbidden), `schedule 分支不许碰 ${forbidden}`) + } +}) + +test('热开关:指令分支 + 设置 watcher + 指纹去重', () => { + assert.match(USE_SCOREBOARD, /case 'schedule':/) + assert.match(USE_SCOREBOARD, /parseScheduleConfig\(command\.payload\.schedule\)/) + assert.match(USE_SCOREBOARD, /if \(parsed !== null\) applyScheduleConfig\(parsed\)/, '坏指令忽略,不清屏') + // watcher 照公告那套:设置里的持久值也当作一份来源(刷新 / 重启不丢)。 + assert.match(USE_SCOREBOARD, /watch\(\s*\(\) => settings\.value\?\.display\?\.schedule \?\? null/) + assert.match(USE_SCOREBOARD, /seenScheduleSignature = scheduleSignature\(null\)/) + assert.match(USE_SCOREBOARD, /if \(signature === seenScheduleSignature\) return/, '同源去重') + // 必须真的暴露给组件,否则写得再好也没上屏。 + assert.match(USE_SCOREBOARD, /\n\s+schedule,\n/) +}) + +test('设置解析层容错:display.schedule → null 也不影响整页设置', () => { + assert.match(GUARDS, /export function parseScheduleConfig\(value: unknown\): ScheduleSidebarConfig \| null/) + assert.match(GUARDS, /if \(!isRecord\(source\)\) return null/) + assert.match(GUARDS, /parseScheduleConfig\(settingValue\(rec, 'display\.schedule', 'display\.schedule'\)\)/) + assert.match(GUARDS, /schedule,\n\s*\},/, 'parseSettings 的 display 里要带上它') +}) + +test('排版复用 fitFont,且不按视口算(防双重缩放)', () => { + assert.match(PANEL, /import \{ MAX_ROW_FONT_PX, MIN_ROW_FONT_PX, fitRowFontPx, textEm \} from '\.\/fitFont'/) + assert.match(PANEL_CODE, /fitRowFontPx\(rows, availablePx \* HEIGHT_SAFETY, 1 \/ factor, MAX_ROW_FONT_PX\)/) + assert.match(PANEL_CODE, /textEm\(text\)/, '按显示宽度估宽要走 fitFont.textEm,不许另造一套') + // 舞台自己会 scale(),侧边栏里再按视口算一次就是双重缩放。 + for (const forbidden of ['window.', 'innerWidth', 'innerHeight', 'vh', 'vw', 'document.']) { + assert.ok(!PANEL_CODE.includes(forbidden), `schedulePanel 里不许出现 ${forbidden}`) + } +}) diff --git a/web/src/display/useScoreboard.ts b/web/src/display/useScoreboard.ts index f009438..aa11aaf 100644 --- a/web/src/display/useScoreboard.ts +++ b/web/src/display/useScoreboard.ts @@ -7,6 +7,7 @@ import type { Event, EventResultGroup, RealtimeStatus, + ScheduleSidebarConfig, StandingRow, Team, } from '@/shared' @@ -14,6 +15,7 @@ import { DEFAULT_ROTATE_SECONDS, parseAthleteStandings, parseLiveFeed, + parseScheduleConfig, parseSettings, parseSnapshot, parseStandingRow, @@ -21,6 +23,7 @@ import { parseTeamBreakdown, } from './guards' import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards' +import { scheduleSignature } from './schedulePanel' import { entryShareIndex, entrySquadIndex } from './jointTeams' import type { DisplayTeamShare } from './jointTeams' import { entryRosterIndex } from './members' @@ -80,6 +83,17 @@ const options = ref({ resultsCount: 3, }) const detail = ref(null) +/** + * 赛事日程侧边栏的当前配置(**热开关**)。 + * + * 两个来源:`display:command` 的 schedule 指令(立刻生效)与设置里的 `display.schedule` + * (服务端持久值,刷新 / 重启不丢)。两者内容同源,靠指纹去重,见 applyScheduleConfig。 + * + * 与 `detail` 状态机**完全无关**:这里只是一个配置 ref,`goto` 关明细、明细停轮播都不碰它。 + */ +const schedule = ref(null) +/** 上一次应用过的日程配置指纹(初始 '' = 与「没有日程」同源)。 */ +let seenScheduleSignature = scheduleSignature(null) const booted = ref(false) /** 公告状态机:全屏退场后正文继续留着,底栏读的就是它。 */ @@ -635,6 +649,13 @@ function handleCommand(command: DisplayCommand): void { case 'announce_clear': announcementStore.clear() break + case 'schedule': { + // 热开关:整份配置即时应用,不等下一次快照(服务端随后也会把持久值推下来,指纹相同不重复应用)。 + // 解析成 null(坏形状 / 老服务端不带这个字段)一律忽略——一条脏指令不能把已经上屏的日程清掉。 + const parsed = parseScheduleConfig(command.payload.schedule) + if (parsed !== null) applyScheduleConfig(parsed) + break + } case 'show-detail': void openDetail(command) break @@ -644,6 +665,19 @@ function handleCommand(command: DisplayCommand): void { } } +/** + * 应用一份日程配置(指令与设置两条路都走它)。 + * + * 指纹相同就原地不动:服务端保存日程时「先转发指令、再落库」,落库后大屏还会收到一份内容 + * 完全一样的设置值;不去重就会让面板重建一次(入场动效重放,看着像闪了一下)。 + */ +function applyScheduleConfig(next: ScheduleSidebarConfig | null): void { + const signature = scheduleSignature(next) + if (signature === seenScheduleSignature) return + seenScheduleSignature = signature + schedule.value = next +} + // 大屏字体随设置变化:快照、缓存、示例数据任何一条路径改动 settings 都要重新挂字体。 watch( settings, @@ -682,6 +716,20 @@ watch( }, ) +/** + * 服务端把赛事日程持久化在设置里(display.schedule),快照与设置接口都带它。 + * + * 与公告同一套理由:指令可能因为断线、重开窗口而错过,所以设置里的值也当作一份来源—— + * 大屏刷新 / 重启后照样是上次配好的样子。指纹去重在 applyScheduleConfig 里, + * 所以「指令刚推过、设置又回推一份」不会让面板抖一下。 + */ +watch( + () => settings.value?.display?.schedule ?? null, + (next) => { + applyScheduleConfig(next) + }, +) + // --------------------------------------------------------------------------- // 生命周期 // --------------------------------------------------------------------------- @@ -816,6 +864,7 @@ export function useScoreboard() { authRequired, options, detail, + schedule, announcement, announceText, booted, diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 70006f4..04ca714 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -681,6 +681,43 @@ export interface RecordBreakInput { * 赛事配置。服务端以 `a.b` 形式存在 settings 表里(如 `display.rotate_seconds`), * 接口按前缀聚合成嵌套对象,前端不必再解析点号键。 */ +/** 赛事日程侧边栏的一条(**手填**,与小项/报名无关,不参与算分)。 */ +export interface ScheduleItem { + /** 时间:**自由文本**(`08:30` / `上午` / `待定` 都行)。系统不解析、不比较、不排序。 */ + time: string + title: string + /** 备注(例如「检录 08:00」)。默认上屏,可用 config.showNote 整体关掉。 */ + note?: string + /** 已完成。「当前项」= 第一个未勾选的项(不做时间比较)。 */ + done?: boolean +} + +/** 单屏覆盖的三态:跟随总开关 / 强制开 / 强制关。 */ +export type ScheduleOverride = 'inherit' | 'on' | 'off' + +/** + * 赛事日程侧边栏的整份配置。 + * + * 存在**赛事库设置**里(随赛事包走),读写走 `POST /api/display/command` 的 `schedule` 动作 + * —— 因为 `PUT /api/settings` 是 admin-only,而现场要求录入员也能切(与大屏公告同档)。 + * 保存是**整份替换**:客户端手上就有整份配置,服务端只做校验与落库。 + * + * ⚠️ 每次保存都会让全场大屏做一次全量 resync,属**低频**动作(开赛前配一次、现场偶尔勾完成)。 + */ +export interface ScheduleSidebarConfig { + /** 上限 40 条(服务端校验)。 */ + items: ScheduleItem[] + /** 总开关:所有大屏的默认值。 */ + visible: boolean + side: 'left' | 'right' + /** 宽度占屏百分比,服务端夹到 15–40。 */ + widthPercent: number + /** 备注是否上屏。 */ + showNote: boolean + /** 按**实例 id**(GET /api/display/instances 的 id)的单屏覆盖;缺省 = inherit。 */ + instanceOverrides?: Record +} + export interface EventSettings { event: { name: string @@ -718,6 +755,11 @@ export interface EventSettings { * 旧服务端不下发时可选,解析层归一成 auto。 */ liveSource?: string | null + /** + * 赛事日程侧边栏(手填的日程 + 开关 + 左右与宽度 + 单屏覆盖)。 + * 旧服务端不下发这个键,所以可选;大屏解析层归一成 null(= 用内置默认:关闭)。 + */ + schedule?: ScheduleSidebarConfig | null } standings: { individualToTeamWeight: number @@ -1205,6 +1247,11 @@ export type DisplayAction = | 'announce_clear' | 'show-detail' | 'hide-detail' + /** + * 保存赛事日程侧边栏(**整份替换**)。与大屏公告同一档:录入员及以上, + * 服务端在同一个请求里既转发指令给大屏、也落库到赛事设置(刷新/重启不丢)。 + */ + | 'schedule' export interface DisplayCommandPayload { page?: string @@ -1216,6 +1263,8 @@ export interface DisplayCommandPayload { title?: string /** 明细由管理端算好后随命令下发,大屏只负责显示(所见即所得)。 */ detail?: TeamBreakdown + /** 赛事日程侧边栏的整份配置(action = 'schedule' 时必填)。 */ + schedule?: ScheduleSidebarConfig } export interface DisplayCommand {