feat(display): 大屏「赛事日程侧边栏」(手填日程 + 竖向进度轴 + 热开关 + 每屏覆盖);版本 dev_0.5.16
主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。
设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 id**。
服务端(走大屏公告那条路 —— PUT /api/settings 是 admin-only,会与「录入员可改」冲突)
- 新增 `schedule` 动作 + 白名单;`POST /api/display/command` 同一个 handler:**先校验 → 再转发 → 最后落库**
(校验排在转发前,非法配置不会在大屏上闪一帧)
- 落库到赛事库 settings 的 `display.schedule`(**不新建表、不写迁移**,随赛事包走);
与公告共用那份「写设置键 + bumpVersion」的实现
- **读写不对称容错**:写侧严格 400(含中文提示与第几条),读侧坏数据一律静默当空 —— 老数据 / 手改库不会炸大屏
- 上限:40 条;time ≤20 / title ≤40 / note ≤60(按字符,清洗控制字符);宽度夹 15–40;覆盖 ≤50 条
- 口径收口三处:**时间允许为空**(与管理端同口径)、**showNote 缺键 = true**(区分「键不存在」与「显式 false」)、
**`inherit` 不落库**(不再白占覆盖预算)
大屏端
- 新增 `ScheduleSidebar.vue` + `schedulePanel.ts`(纯函数:开窗 / 三态 / 整体进度 / 字号与行数 / 按显示宽度截断 /
单屏覆盖 / 抑制 / 指纹去重)
- 字号是**舞台设计坐标**(1920×1080),靠 `.stage` 的 `transform: scale()` 整体缩放,**不做二次缩放**
(wiring 断言禁止出现 `window.`/`innerWidth`/`vh`);复用既有的 `fitRowFontPx`(**16–56px 硬上下限**)与 `textEm`;
固定点迭代 ≤4 轮;**贴下限时宁可行数少也不缩字**;标题 ≤3 行、备注 ≤2 行,超出截断
- 容器 `position:absolute; inset:0; z-index:50`(**不用 fixed** —— `.stage` 带 transform,是 fixed 后代的包含块);
**独立于 detail 状态机**(goto 会关明细、明细会停轮播,侧边栏必须不受影响)
- 深色半透明 + `backdrop-filter`,并带 `@supports not (backdrop-filter: blur(1px))` 退化为更不透明的深底
- 关着时**不留任何 DOM**(逐字节等同于没有这个功能);`prefers-reduced-motion` 下不做动效
管理端
- 大屏控制页(左列、公告之后)新增「赛事日程」卡片:条目表格(时间/标题/备注/已完成 + 上移/下移/删除)、
总开关、备注上屏、位置与宽度、**每块屏三态覆盖**(跟随 / 强制开 / 强制关;切回「跟随」会真删掉那条键)
- 保存走 `adminApi.displayCommand`(**不走 admin-only 的设置接口**);本地校验不过**一个请求都不发**
- 排序只有上移 / 下移(该页面禁止挂 pointerdown/mousedown 监听,有既有测试钉着)
测试
- 服务端 16 个包全绿;前端 **1174 例**全过(基线 1096,本批 +78:大屏端 44 + 管理端 34)
- 大屏端另有 **40 项 playwright 实机**(用 `page.routeWebSocket` 假造实时通道):开/关后 DOM 无残留、
五个页面下都在、`lock=0` 真等轮播翻页且侧边栏不掉、明细与公告期间自动收起并自动回来、
**1080p 与 4K 字号完全相同**、`reducedMotion` 下无动画;并钉住两条硬指标(真机列表高度 ≥ 纯函数预算、
容量×行高 ≤ 真机高度)
文档:`docs/升级说明` 新增 `dev_0.5.16`;`使用手册` 9.2 + 17.6;`常见问题` Q108–Q111;`上线检查清单`/`部署指南`
各一条;`devdocs/05·07·13` 同步。
⚠️ 一条**既有边界**(本版故意没改,按主人「兼容性最大化」的要求):导出的名次口径是「通道名次优先」、
屏幕是「成绩行名次优先」,只在「先按决赛计分发布、之后又只更新名次发布一次」这种序列下分叉
(导出显示上次计分的名次)。正常赛程遇不到;遇到时对该组再「按决赛成绩发布」一次即恢复一致。
已写进《升级说明》④ 与《常见问题》Q111。
This commit is contained in:
1 parent
56ed90ff8b
commit
9c52dc4b38
33 files changed
+4736
-10
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 309 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -1 +1 @@
|
|||||||
0.5.15
|
0.5.16
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scoreboard-console",
|
"name": "scoreboard-console",
|
||||||
"productName": "Scoreboard Console",
|
"productName": "Scoreboard Console",
|
||||||
"version": "0.5.15",
|
"version": "0.5.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "AskaEth",
|
"author": "AskaEth",
|
||||||
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
- [ ] **局域网大屏能在别的设备打开**:用另一台设备(平板 / 手机)浏览器打开 `http://<主机IP>:19540/display` 能出画面,或直接扫「大屏控制 → 局域网大屏」卡片第一个地址的**二维码**;打不开时先看卡片列出的地址与提示,再按常见问题「大屏控制页显示未检测到局域网地址怎么办?」逐条排查(若卡片地址是 `169.254.x.x`,说明本网段没有 DHCP,同网段的设备照样能连)
|
- [ ] **局域网大屏能在别的设备打开**:用另一台设备(平板 / 手机)浏览器打开 `http://<主机IP>:19540/display` 能出画面,或直接扫「大屏控制 → 局域网大屏」卡片第一个地址的**二维码**;打不开时先看卡片列出的地址与提示,再按常见问题「大屏控制页显示未检测到局域网地址怎么办?」逐条排查(若卡片地址是 `169.254.x.x`,说明本网段没有 DHCP,同网段的设备照样能连)
|
||||||
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
|
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
|
||||||
- [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏)
|
- [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏)
|
||||||
|
- [ ] **赛事日程侧边栏可用**:到「大屏控制 → 赛事日程」(左列、公告卡下面)加两条日程 → 点「保存」→ 大屏**当前页上**应当叠出侧边栏(默认右侧 22 %)、顶部读数 `已完成 0 / 共 2`,**大屏不换页、轮播不停** → 勾一条「已完成」再保存 → 那条淡化、读数变 `已完成 1 / 共 2` → 打开一次积分明细覆盖层(或推一条公告):侧边栏应**自动收起**,关掉后**自己回来** → 最后**按今天的口径决定总开关**(不公示就关掉,关掉后大屏应恢复原样),并用「每块屏单独设置」把不该显示的那块屏设成「强制关」。⚠️ 这是**低频动作**(每次保存全场大屏全量重拉一次),开赛前配一次即可(见使用手册 9.2 / 17.6)
|
||||||
- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布(发布窗口里按当天口径选「只更新名次」或「按决赛成绩发布」,选完才确认)→ 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布)
|
- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布(发布窗口里按当天口径选「只更新名次」或「按决赛成绩发布」,选完才确认)→ 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布)
|
||||||
- [ ] **分项成绩页看得全**:切到大屏「分项成绩」页,**每一栏(含第 3 栏)都从第 1 名开始**;**团体项目的名次列不应是 `-`**(升级后不用重发、不用重算,刷新即可)。⚠️ 例外:只在「团体名次」卡片里按单位录了名次、成绩行上没填的项目仍无大屏名次(见常见问题 Q105 / Q106)
|
- [ ] **分项成绩页看得全**:切到大屏「分项成绩」页,**每一栏(含第 3 栏)都从第 1 名开始**;**团体项目的名次列不应是 `-`**(升级后不用重发、不用重算,刷新即可)。⚠️ 例外:只在「团体名次」卡片里按单位录了名次、成绩行上没填的项目仍无大屏名次(见常见问题 Q105 / Q106)
|
||||||
- [ ] **报名项批量操作演练**:在「成绩录入」勾选几行试一次**批量退赛 → 批量恢复**(确认框会逐条列出被跳过的 dq / dns 行及原因);「批量移出报名」是**物理删除、不可恢复**,只演示确认框、不要在真实报名上执行(详见使用手册 4.8)
|
- [ ] **报名项批量操作演练**:在「成绩录入」勾选几行试一次**批量退赛 → 批量恢复**(确认框会逐条列出被跳过的 dq / dns 行及原因);「批量移出报名」是**物理删除、不可恢复**,只演示确认框、不要在真实报名上执行(详见使用手册 4.8)
|
||||||
|
|||||||
@@ -628,6 +628,8 @@
|
|||||||
- **保存**:改完模式或项目后点「保存」,提示「实时赛况设置已保存」后大屏按新配置显示;卡片底部注明「当前项目最多 2 个」;
|
- **保存**:改完模式或项目后点「保存」,提示「实时赛况设置已保存」后大屏按新配置显示;卡片底部注明「当前项目最多 2 个」;
|
||||||
- 该卡片的「保存」需要**管理员**身份(见本节开头的权限说明);示例数据模式下不能保存。
|
- 该卡片的「保存」需要**管理员**身份(见本节开头的权限说明);示例数据模式下不能保存。
|
||||||
|
|
||||||
|
**赛事日程**:左列「公告」卡片下面那张「赛事日程」卡片,把**手填的日程**做成大屏上的一条**竖向进度轴侧边栏**(**可热开关**,叠在大屏**当前页之上**,不影响轮播)。**录入员及以上**就能改。卡片每一项怎么填、大屏上长什么样、什么时候会自动收起,见本节 **9.2**;大屏侧的观感与行为另见 **17.6**。
|
||||||
|
|
||||||
**在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次(浏览器切到后台时暂停,切回来立刻补一次)。每个实例一行,显示**设备名**、在线 / 离线徽标、「局域网浏览器」或「屏 N」标记、当前所在页、来源地址与最后心跳;服务端判定某个实例落后时还会多一个「落后」徽标(大屏可能在卡或网络差)。**本机用 Console 打开的大屏窗口、以及局域网设备打开 `/display` 连上的浏览器,都会出现在这里**——局域网浏览器没有屏号,那一栏显示「局域网浏览器」。离线实例会在服务端保留一段时间,方便排查它是什么时候掉的。
|
**在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次(浏览器切到后台时暂停,切回来立刻补一次)。每个实例一行,显示**设备名**、在线 / 离线徽标、「局域网浏览器」或「屏 N」标记、当前所在页、来源地址与最后心跳;服务端判定某个实例落后时还会多一个「落后」徽标(大屏可能在卡或网络差)。**本机用 Console 打开的大屏窗口、以及局域网设备打开 `/display` 连上的浏览器,都会出现在这里**——局域网浏览器没有屏号,那一栏显示「局域网浏览器」。离线实例会在服务端保留一段时间,方便排查它是什么时候掉的。
|
||||||
|
|
||||||
**设备名怎么来的(自动标签 + 手动改名)**:局域网浏览器连上来时会报一个**自动设备标签**,形如 `安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`,写法是 **系统 + 版本 · 浏览器 · 分辨率 · 形态(手机 / 平板 / 桌面)**,某个段取不到就跳过(例:`iPadOS 17 · Safari 17 · 2048×2732 @2x · 平板`、`Windows 10 · Edge 120 · 1920×1080 · 桌面`、`安卓 13 · 微信 8 · 1080×2340 @2.75x · 手机`)。
|
**设备名怎么来的(自动标签 + 手动改名)**:局域网浏览器连上来时会报一个**自动设备标签**,形如 `安卓 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 的选屏配置里。
|
多块副屏都被关掉时,在同一张卡片里逐块点「创建全屏窗口」即可;改用「重新排布」也行,但前提是这些屏都在 Console 的选屏配置里。
|
||||||
|
|
||||||
|
### 9.2 赛事日程(手填日程 + 大屏上的侧边栏)
|
||||||
|
|
||||||
|
**这张卡片是干什么的**:把**手填的赛事日程**做成大屏上一块**侧边栏**,里面是一条**竖向进度轴**。它**与大屏当前在哪一页无关**(轮播中、锁在某一页都照常显示),也**不影响**切页与轮播。**录入员及以上**可改(与大屏公告、逐屏切页同档);未登录 / 服务端未连接时整张卡片禁用并写明原因;示例数据模式下会提示改动未真正保存。
|
||||||
|
|
||||||
|
**位置**:本页**左列、公告卡之后、「实时赛况」之前**;卡头徽标写 `N 条 · 默认开启 / 关闭`。
|
||||||
|
|
||||||
|
**表格怎么填**:
|
||||||
|
|
||||||
|
| 列 | 怎么填 |
|
||||||
|
| --- | --- |
|
||||||
|
| 时间 | **自由文本**,最长 **20** 字(`08:30`、`上午`、`待定` 都行)。⚠️ **必须填**(不能留空),否则保存时服务端会退回一句「第 N 条日程的时间不能为空」 |
|
||||||
|
| 标题 | **必填**,最长 **40** 字 |
|
||||||
|
| 备注 | 可留空,最长 **60** 字(例如 `检录 08:00`);**默认上屏**,可用下面的「备注上屏」整体关掉 |
|
||||||
|
| 已完成 | 勾上 = 这条已完成(大屏上会**淡化保留**) |
|
||||||
|
| 操作 | **上移 / 下移 / 删除**——**只有这三个按钮,不能拖拽排序**;第一行的「上移」、最后一行的「下移」是灰的 |
|
||||||
|
|
||||||
|
- 表格下方是「**新增一条**」按钮,右侧写 `共 N / 40 条`(**最多 40 条**,满了按钮不可点);一条都没有时显示「还没有日程条目」;
|
||||||
|
- **上屏顺序就是你排的顺序**:系统**不解析时间、不比较、也不按时间自动排序**(手填文本没法可靠解析);
|
||||||
|
- **校验不过时一个请求都不发**:字段级红字贴在该格下方,条数 / 宽度这类整表错误列在表格下方,同时提示「赛事日程校验未通过 / 请先修正标红的字段」。
|
||||||
|
|
||||||
|
**开关与几何**:
|
||||||
|
|
||||||
|
- **侧边栏默认开启**:总开关。开 = 所有大屏默认显示(除非那块屏被单独设成「强制关」);关 = 所有大屏都不显示;
|
||||||
|
- **备注上屏**:关掉后大屏上只显示时间与标题;
|
||||||
|
- **位置**:左侧 / 右侧(默认**右侧**);**宽度**:占整屏的百分比,**15–40 %**(默认 **22 %**)。
|
||||||
|
|
||||||
|
**每块屏单独设置**(覆盖上面的总开关):卡片下半部分**一行一块屏**,显示名按 **手动别名 → 自动设备标签 → 实例 id**(怎么改名见本节前面「在线大屏」那段),右边一个三态下拉:
|
||||||
|
|
||||||
|
| 选项 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 跟随总开关(默认) | 由上面的总开关决定 |
|
||||||
|
| 强制开 | 这块屏**无论总开关怎么设都显示** |
|
||||||
|
| 强制关 | 这块屏**不显示** |
|
||||||
|
|
||||||
|
- 切回「**跟随总开关**」会**真的把那条覆盖删掉**(不会留一个没用的空键);
|
||||||
|
- 列表里**列全部在线实例**,另外把「离线、但身上挂着强制开 / 强制关」的也列出来(否则那块平板今天没开,你就再也撤不掉它的强制关了);**还没有任何大屏上报**时这里显示「还没有大屏上报,暂时没有可单独设置的设备;新设备接上后回到这里即可」;列表里已经找不到的覆盖条数会另写一句说明——那些设置**照原样保存**,不会被悄悄删掉。
|
||||||
|
|
||||||
|
**保存**:点「保存」= **整份替换**并**立刻推给所有大屏**(这就是「热开关」:不用刷新、不用重启)。成功提示形如「已把赛事日程改成「共 2 条 · 默认开启 · 右侧 22%」」,并注明「大屏立刻生效;配置随赛事库保存,刷新与重启都不会丢」;失败会把**服务端原文**显示出来(条数超限 / 字段过长 / 权限不足)。
|
||||||
|
|
||||||
|
⚠️ **这是低频动作**:每次保存都会让**全场大屏做一次全量重拉**(局域网内几百毫秒,现场基本无感)——所以它适合「**开赛前配一次 + 现场偶尔勾一下「已完成」**」,**不要**拿它做高频刷新。
|
||||||
|
|
||||||
|
**配置存在哪**:赛事库的设置里(键名 `display.schedule`,与大屏公告同一套机制)——**不新建表、不改数据库结构**,**随赛事包一起导出 / 导入**;刷新、重启服务端、切换赛事项目都不会丢。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 基础数据
|
## 10. 基础数据
|
||||||
@@ -1274,6 +1319,28 @@
|
|||||||
- **kiosk 全屏窗口**(多屏时每屏一个)不显示任何调试标识,正式上屏的画面是干净的;多屏 kiosk 也不响应 F11。
|
- **kiosk 全屏窗口**(多屏时每屏一个)不显示任何调试标识,正式上屏的画面是干净的;多屏 kiosk 也不响应 F11。
|
||||||
- 单屏窗口化窗口按 **F11** 全屏后,自绘标题栏与「实例 · 屏」会一起消失、画面铺满整块屏;再按一次 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. 常见操作问题
|
## 18. 常见操作问题
|
||||||
|
|||||||
@@ -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
|
## dev_0.5.15
|
||||||
|
|
||||||
### 一句话
|
### 一句话
|
||||||
|
|||||||
@@ -801,3 +801,52 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
|||||||
- **以前给某台设备切页后,列表底部会多出一个该设备的离线条目**(根因是翻页会补发一次握手,而浏览器没有稳定 id、服务端每次都新开一条实例),**现在已经修好**:浏览器本机记住自己的实例 id,服务端也做了兜底(同一条连接重复握手复用原实例),升级后不会再出现这种重复条目。
|
- **以前给某台设备切页后,列表底部会多出一个该设备的离线条目**(根因是翻页会补发一次握手,而浏览器没有稳定 id、服务端每次都新开一条实例),**现在已经修好**:浏览器本机记住自己的实例 id,服务端也做了兜底(同一条连接重复握手复用原实例),升级后不会再出现这种重复条目。
|
||||||
|
|
||||||
顺带一句:「局域网大屏」卡片里**每一条地址**都能出二维码了——第一条是常驻二维码,其余每条点「**二维码**」按钮临时出码(再点一次 / 按 Esc / 点空白处收起,同一时刻只留一个),不用为别的网段再手敲地址。详见《使用手册》第 9 节、《部署指南》7.5。
|
顺带一句:「局域网大屏」卡片里**每一条地址**都能出二维码了——第一条是常驻二维码,其余每条点「**二维码**」按钮临时出码(再点一次 / 按 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` 的 ④)。
|
||||||
|
|
||||||
|
**遇到了怎么办**:对该小项 + 组别**再按「按决赛成绩发布」发布一次**即可(通道名次会按当前成绩重建,两边立刻一致),**不用改库、不用重导、也不用重算整场**。正常赛程(预赛只排名次 → 决赛才计分)两边名次本来就是同一个值,不会遇到。
|
||||||
@@ -751,6 +751,7 @@ http://<主机IP>:19540/display
|
|||||||
- [ ] **单位 logo 已上传且在榜上显示**(总积分榜 / 单位奖牌榜 / 分项成绩表 / 个人积分榜,见 7.7)
|
- [ ] **单位 logo 已上传且在榜上显示**(总积分榜 / 单位奖牌榜 / 分项成绩表 / 个人积分榜,见 7.7)
|
||||||
- [ ] **大屏字体已选好并核对**:「系统设置 → 赛事信息 → 字体」里选了标题字体与正文字体,大屏上显示正常;上传的字体在数据目录 `event_projs\<项目名>\uploads\fonts\` 里,导出赛事项目包会一并带走(见 8.5)
|
- [ ] **大屏字体已选好并核对**:「系统设置 → 赛事信息 → 字体」里选了标题字体与正文字体,大屏上显示正常;上传的字体在数据目录 `event_projs\<项目名>\uploads\fonts\` 里,导出赛事项目包会一并带走(见 8.5)
|
||||||
- [ ] **公告可用**:推送一条测试公告,全屏到时后底栏仍能看到正文,点「清除公告」后底栏回到「暂无公告」(见 7.6)
|
- [ ] **公告可用**:推送一条测试公告,全屏到时后底栏仍能看到正文,点「清除公告」后底栏回到「暂无公告」(见 7.6)
|
||||||
|
- [ ] **赛事日程侧边栏(本版新增,用不用都过一遍开关)**:到「大屏控制 → 赛事日程」(左列、公告卡下面)加两条日程 → 保存 → 大屏**当前页上**叠出侧边栏(默认右侧 22 %)、读数 `已完成 0 / 共 2`,**大屏不换页、轮播不停**;勾一条「已完成」再保存,着色与读数跟着变;打开积分明细或推一条公告时侧边栏**自动收起**、关掉后**自己回来**;**不用就在开赛前把总开关关掉**(关掉后大屏与没有这个功能时一致)。⚠️ 每次保存都会让全场大屏全量重拉一次,是**低频动作**(见《使用手册》9.2 / 17.6)
|
||||||
- [ ] 管理端各角色账号可登录,权限正确
|
- [ ] 管理端各角色账号可登录,权限正确
|
||||||
- [ ] 主机 IP 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台
|
- [ ] 主机 IP 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台
|
||||||
- [ ] 防熄屏、屏保、休眠、系统通知已关闭
|
- [ ] 防熄屏、屏保、休眠、系统通知已关闭
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构,
|
// EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构,
|
||||||
@@ -48,6 +50,208 @@ type EventSettingsDisplay struct {
|
|||||||
// LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走
|
// LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走
|
||||||
// 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
|
// 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
|
||||||
LiveSource string `json:"liveSource"`
|
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.LiveEvents = ParseLiveEventIDs(values[SettingKeyLiveEvents])
|
||||||
out.Display.LiveSource = NormalizeLiveSource(values[SettingKeyLiveSource])
|
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.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
||||||
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package dto
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||||
@@ -289,3 +290,198 @@ func TestTeamAndEventFromDomainNullableFields(t *testing.T) {
|
|||||||
t.Fatalf("sortOrder 应保留 0: %s", eventRaw)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -318,6 +322,127 @@ func announceFromPayload(payload any) (string, int) {
|
|||||||
return text, int(seconds)
|
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 检索的
|
// displayCommandAudit 是 display.command 的审计载荷:指令内容原样保留(现场按 action 检索的
|
||||||
// 既有查询不受影响),另外记上定向目标与实际命中的实例数。
|
// 既有查询不受影响),另外记上定向目标与实际命中的实例数。
|
||||||
type displayCommandAudit struct {
|
type displayCommandAudit struct {
|
||||||
@@ -333,6 +458,19 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
|
|||||||
if in.Action == "" {
|
if in.Action == "" {
|
||||||
return nil, badRequest("action 必填", nil)
|
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
|
matched := 0
|
||||||
// 没接实时层(s.display == nil)时不转发,但下面的公告落库照旧:公告能被后连上来的
|
// 没接实时层(s.display == nil)时不转发,但下面的公告落库照旧:公告能被后连上来的
|
||||||
// 大屏读到靠的是 settings + change_log,与这一次广播无关。
|
// 大屏读到靠的是 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 {
|
if err := s.clearAnnouncement(r.Context(), actorID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 日程与公告同一个形状:上面已经把指令转发出去了,这里再把持久值写掉 ——
|
||||||
|
// 只转发不落库的话,大屏刷新 / 重启(或后连上来)就再也看不到当前日程了。
|
||||||
|
case realtime.CmdSchedule:
|
||||||
|
if err := s.saveSchedule(r.Context(), schedule, actorID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 审计沿用同一条 action:现场按 action=display.command 检索的既有查询不受影响,
|
// 审计沿用同一条 action:现场按 action=display.command 检索的既有查询不受影响,
|
||||||
// after 里带了这次的具体 action、公告正文、定向目标与命中数。
|
// after 里带了这次的具体 action、公告正文、定向目标与命中数。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -373,6 +374,7 @@ func (s *server) writeSessionHours(ctx context.Context, hours int) error {
|
|||||||
|
|
||||||
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
|
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
|
||||||
// 而 PUT /api/settings 是 admin-only,公告的持久化只能走指令这一侧。
|
// 而 PUT /api/settings 是 admin-only,公告的持久化只能走指令这一侧。
|
||||||
|
// 赛事日程侧边栏走的是同一条路(见下面的 saveSchedule)。
|
||||||
const (
|
const (
|
||||||
announceTextKey = "display.announce_text"
|
announceTextKey = "display.announce_text"
|
||||||
announceSecondsKey = "display.announce_seconds"
|
announceSecondsKey = "display.announce_seconds"
|
||||||
@@ -384,17 +386,19 @@ func (s *server) saveAnnouncement(ctx context.Context, text string, seconds int,
|
|||||||
if seconds <= 0 {
|
if seconds <= 0 {
|
||||||
seconds = defaultAnnounceSeconds
|
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 清空当前公告;全屏时长保留,供下次推送沿用。
|
// clearAnnouncement 清空当前公告;全屏时长保留,供下次推送沿用。
|
||||||
func (s *server) clearAnnouncement(ctx context.Context, actorID int64) error {
|
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 变更。
|
// writeSettingValues 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。
|
||||||
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到公告变化。
|
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到变化。
|
||||||
func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...string) error {
|
//
|
||||||
|
// 名字里不绑具体的键:公告与赛事日程都从这条路落库,事务与 change_log 的写法只此一份。
|
||||||
|
func (s *server) writeSettingValues(ctx context.Context, actorID int64, kv ...string) error {
|
||||||
tx, err := s.ev.BeginTx(ctx, nil)
|
tx, err := s.ev.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return internal(err)
|
return internal(err)
|
||||||
@@ -422,6 +426,24 @@ func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...str
|
|||||||
return nil
|
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 ----------
|
// ---------- standings ----------
|
||||||
|
|
||||||
func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) {
|
func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) {
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
|
|||||||
assertKeys(t, "settings.event", asMap(t, settings["event"]),
|
assertKeys(t, "settings.event", asMap(t, settings["event"]),
|
||||||
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
|
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
|
||||||
assertKeys(t, "settings.display", asMap(t, settings["display"]),
|
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"]),
|
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
|
||||||
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"})
|
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"})
|
||||||
|
|
||||||
|
|||||||
@@ -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,局域网浏览器不带(空串),两者都要能查出来。
|
// 屏号上报:Console 窗口带 screen,局域网浏览器不带(空串),两者都要能查出来。
|
||||||
func TestDisplayInstancesReportScreen(t *testing.T) {
|
func TestDisplayInstancesReportScreen(t *testing.T) {
|
||||||
env := newTestEnv(t)
|
env := newTestEnv(t)
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ const (
|
|||||||
CmdHideDetail = "hide-detail"
|
CmdHideDetail = "hide-detail"
|
||||||
// CmdAnnounceClear 清除当前公告:服务端同时清设置里的持久值并广播给大屏。
|
// CmdAnnounceClear 清除当前公告:服务端同时清设置里的持久值并广播给大屏。
|
||||||
CmdAnnounceClear = "announce_clear"
|
CmdAnnounceClear = "announce_clear"
|
||||||
|
// CmdSchedule 整份替换「赛事日程侧边栏」配置(payload 形状见 dto.ScheduleConfig):
|
||||||
|
// 服务端校验后一次请求里既转发给大屏、也落库到 settings,刷新 / 重启不丢(与公告同路)。
|
||||||
|
CmdSchedule = "schedule"
|
||||||
)
|
)
|
||||||
|
|
||||||
var displayActions = map[string]bool{
|
var displayActions = map[string]bool{
|
||||||
@@ -75,6 +78,7 @@ var displayActions = map[string]bool{
|
|||||||
CmdAnnounceClear: true,
|
CmdAnnounceClear: true,
|
||||||
CmdShowDetail: true,
|
CmdShowDetail: true,
|
||||||
CmdHideDetail: true,
|
CmdHideDetail: true,
|
||||||
|
CmdSchedule: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。
|
// ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。
|
||||||
|
|||||||
@@ -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) {
|
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := db.Exec(q, args...); err != nil {
|
if _, err := db.Exec(q, args...); err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { formatDateTime, type EventSettings, type LiveEvents, type NetworkInfo } from '@/shared'
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
type EventSettings,
|
||||||
|
type LiveEvents,
|
||||||
|
type NetworkInfo,
|
||||||
|
type ScheduleOverride,
|
||||||
|
type ScheduleSidebarConfig,
|
||||||
|
} from '@/shared'
|
||||||
import type { DisplayInstance } from '@/shared/api'
|
import type { DisplayInstance } from '@/shared/api'
|
||||||
import { DISPLAY_PAGES, type DisplayPageKey } from '@/display/types'
|
import { DISPLAY_PAGES, type DisplayPageKey } from '@/display/types'
|
||||||
import { encodeQr, qrToSvg } from '@/shared/qrcode'
|
import { encodeQr, qrToSvg } from '@/shared/qrcode'
|
||||||
@@ -32,6 +39,32 @@ import {
|
|||||||
toggleQr,
|
toggleQr,
|
||||||
writeManualAddresses,
|
writeManualAddresses,
|
||||||
} from './lanDisplay'
|
} from './lanDisplay'
|
||||||
|
import {
|
||||||
|
SCHEDULE_MAX_ITEMS,
|
||||||
|
SCHEDULE_NOTE_MAX,
|
||||||
|
SCHEDULE_OVERRIDE_OPTIONS,
|
||||||
|
SCHEDULE_SIDE_OPTIONS,
|
||||||
|
SCHEDULE_TIME_MAX,
|
||||||
|
SCHEDULE_TITLE_MAX,
|
||||||
|
SCHEDULE_WIDTH_MAX,
|
||||||
|
SCHEDULE_WIDTH_MIN,
|
||||||
|
emptyScheduleErrors,
|
||||||
|
emptyScheduleRow,
|
||||||
|
hasScheduleErrors,
|
||||||
|
hiddenOverrideCount,
|
||||||
|
moveScheduleRow,
|
||||||
|
normalizeScheduleConfig,
|
||||||
|
overrideOf,
|
||||||
|
overrideTargets,
|
||||||
|
rowsFromSchedule,
|
||||||
|
scheduleCommand,
|
||||||
|
scheduleConfigFrom,
|
||||||
|
scheduleSummary,
|
||||||
|
validateSchedule,
|
||||||
|
withOverride,
|
||||||
|
type ScheduleErrors,
|
||||||
|
type ScheduleRow,
|
||||||
|
} from './scheduleEditor'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 页面选项直接取大屏那份清单(`@/display/types`):规范页名与中文标签只维护一处,
|
* 页面选项直接取大屏那份清单(`@/display/types`):规范页名与中文标签只维护一处,
|
||||||
@@ -158,6 +191,12 @@ async function saveLive(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读一次完整设置,把本页三张卡片(公告 / 实时赛况 / 赛事日程)的本地状态对齐到服务端。
|
||||||
|
*
|
||||||
|
* 日程那半边由别处推过也会改到(另一台管理面板、或服务端夹紧了宽度),所以这里同样以
|
||||||
|
* 服务端返回的那份为准;**只在挂载时跑一次**,编辑中的行不会被后台刷新冲掉。
|
||||||
|
*/
|
||||||
async function refreshSettings(): Promise<void> {
|
async function refreshSettings(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const settings = await adminApi.client.settings.get()
|
const settings = await adminApi.client.settings.get()
|
||||||
@@ -165,6 +204,7 @@ async function refreshSettings(): Promise<void> {
|
|||||||
currentAnnounce.value = settings.display?.announce ?? null
|
currentAnnounce.value = settings.display?.announce ?? null
|
||||||
liveSource.value = settings.display?.liveSource === 'manual' ? 'manual' : 'auto'
|
liveSource.value = settings.display?.liveSource === 'manual' ? 'manual' : 'auto'
|
||||||
liveEvents.value = [...(settings.display?.liveEvents ?? [])]
|
liveEvents.value = [...(settings.display?.liveEvents ?? [])]
|
||||||
|
applyScheduleConfig(settings.display?.schedule ?? null)
|
||||||
} catch {
|
} catch {
|
||||||
// 示例数据 / 断网时读不到设置,保留本地这一份,不能把「当前公告」清成未知。
|
// 示例数据 / 断网时读不到设置,保留本地这一份,不能把「当前公告」清成未知。
|
||||||
}
|
}
|
||||||
@@ -678,6 +718,127 @@ const liveGateReason = computed(() => {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 赛事日程侧边栏(手填日程 + 总开关 + 单屏覆盖) ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这张卡片为什么走 `POST /api/display/command` 而不是 `PUT /api/settings`。
|
||||||
|
*
|
||||||
|
* 上方那张「实时赛况」卡是 admin-only 的(settings 接口只有管理员能写),所以它老老实实
|
||||||
|
* 给非管理员置灰;日程这条需求反过来点名了「录入员及以上」,服务端也因此把它放在
|
||||||
|
* display:command(与大屏公告同一条路,那条路本来就是 recorder 档)。**不要**顺手改成
|
||||||
|
* settingsApi.save —— 那不是「更一致」,而是把录入员的保存变成必然 403。
|
||||||
|
*/
|
||||||
|
const scheduleRows = ref<ScheduleRow[]>([])
|
||||||
|
const scheduleVisible = ref(false)
|
||||||
|
const scheduleSide = ref<'left' | 'right'>('right')
|
||||||
|
/** 宽度:输入框清空时是空串(`v-model.number` 保持原值),校验按「非数字」拦下。 */
|
||||||
|
const scheduleWidth = ref<number | string>(22)
|
||||||
|
const scheduleShowNote = ref(true)
|
||||||
|
/** 键是**实例 id**(与大屏手动别名同一把钥匙),不是屏号也不是下标。 */
|
||||||
|
const scheduleOverrides = ref<Record<string, ScheduleOverride>>({})
|
||||||
|
const scheduleErrors = ref<ScheduleErrors>(emptyScheduleErrors())
|
||||||
|
const scheduleSaving = ref(false)
|
||||||
|
|
||||||
|
/** 行 key 单调递增:删一行再加一行不会撞 key(撞了红字与 v-for 都会跟错行)。 */
|
||||||
|
let scheduleRowSeq = 0
|
||||||
|
function takeScheduleKey(): number {
|
||||||
|
scheduleRowSeq += 1
|
||||||
|
return scheduleRowSeq
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务端那份配置 → 界面状态;读不到(老服务端 / 坏 JSON)时按默认值收下。 */
|
||||||
|
function applyScheduleConfig(raw: ScheduleSidebarConfig | null | undefined): void {
|
||||||
|
const config = normalizeScheduleConfig(raw)
|
||||||
|
scheduleRows.value = rowsFromSchedule(config, () => takeScheduleKey())
|
||||||
|
scheduleVisible.value = config.visible
|
||||||
|
scheduleSide.value = config.side
|
||||||
|
scheduleWidth.value = config.widthPercent
|
||||||
|
scheduleShowNote.value = config.showNote
|
||||||
|
scheduleOverrides.value = { ...(config.instanceOverrides ?? {}) }
|
||||||
|
scheduleErrors.value = emptyScheduleErrors()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先拿启动时已经加载到的那份设置垫上,避免刷新前卡片空着;refreshSettings 随后会核对一次。
|
||||||
|
applyScheduleConfig(data.settings?.display?.schedule ?? null)
|
||||||
|
|
||||||
|
/** 非 null 表示整张卡片是只读的;与「改名」按钮同一口径(录入员及以上 = canWrite)。 */
|
||||||
|
const scheduleGateReason = computed(() =>
|
||||||
|
canWrite.value ? null : auth.readOnly ? '请先登录后再修改赛事日程。' : '服务端未连接,先恢复连接再修改赛事日程。',
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 权限按下去了、或正在提交时,输入控件一起禁用(提交中改一行会让发出去的与看到的不一致)。 */
|
||||||
|
const scheduleLocked = computed(() => scheduleGateReason.value !== null || scheduleSaving.value)
|
||||||
|
|
||||||
|
/** 列表只列在线实例 + 身上挂着非 inherit 覆盖的离线实例(见 scheduleEditor 里的解释)。 */
|
||||||
|
const scheduleTargets = computed(() => overrideTargets(instances.value, scheduleOverrides.value))
|
||||||
|
|
||||||
|
/** 列表里已经没有的覆盖条数:设置照常提交,只是说明一句,别让人以为清掉了。 */
|
||||||
|
const scheduleHiddenOverrides = computed(() => hiddenOverrideCount(instances.value, scheduleOverrides.value))
|
||||||
|
|
||||||
|
/** 底部那几句与行无关的校验红字(条数上限、宽度越界)。 */
|
||||||
|
const scheduleFormErrors = computed(() => scheduleErrors.value.form)
|
||||||
|
|
||||||
|
function addScheduleRow(): void {
|
||||||
|
scheduleRows.value = [...scheduleRows.value, emptyScheduleRow(takeScheduleKey())]
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScheduleRow(key: number): void {
|
||||||
|
scheduleRows.value = scheduleRows.value.filter((row) => row.key !== key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上移 / 下移:越界时 moveScheduleRow 原样返回同一个引用,这里也就不会产生任何变更。 */
|
||||||
|
function moveRow(index: number, delta: number): void {
|
||||||
|
scheduleRows.value = moveScheduleRow(scheduleRows.value, index, delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三态下拉:只认选项表里的值,别的(选中项被程序改坏)当没发生。 */
|
||||||
|
function pickScheduleOverride(instanceId: string, event: Event): void {
|
||||||
|
const value = (event.target as HTMLSelectElement).value
|
||||||
|
if (!SCHEDULE_OVERRIDE_OPTIONS.some((option) => option.value === value)) return
|
||||||
|
scheduleOverrides.value = withOverride(scheduleOverrides.value, instanceId, value as ScheduleOverride)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSchedule(): Promise<void> {
|
||||||
|
if (!canWrite.value) {
|
||||||
|
toast.error('当前不可保存赛事日程', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.isMock) {
|
||||||
|
// 示例数据模式下 callWrite 会直接抛错;先给一句人话,别让人对着「操作失败」猜原因。
|
||||||
|
toast.warning('示例数据模式未真正保存', '后端未就绪,改动不会写入赛事库')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const width = Number(String(scheduleWidth.value).trim())
|
||||||
|
const errors = validateSchedule(scheduleRows.value, scheduleWidth.value)
|
||||||
|
scheduleErrors.value = errors
|
||||||
|
if (hasScheduleErrors(errors)) {
|
||||||
|
// 本地先拦下:不合规就标红并不提交,不等服务端 400(那边只回一句话,指不到是哪一行)。
|
||||||
|
toast.error('赛事日程校验未通过', '请先修正标红的字段')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const config = scheduleConfigFrom(scheduleRows.value, {
|
||||||
|
visible: scheduleVisible.value,
|
||||||
|
side: scheduleSide.value,
|
||||||
|
widthPercent: width,
|
||||||
|
showNote: scheduleShowNote.value,
|
||||||
|
instanceOverrides: scheduleOverrides.value,
|
||||||
|
})
|
||||||
|
scheduleSaving.value = true
|
||||||
|
try {
|
||||||
|
// 整份替换 + 广播:客户端手上就有整份配置,服务端只做校验与落库,不做局部合并。
|
||||||
|
await adminApi.displayCommand(scheduleCommand(config))
|
||||||
|
toast.success(
|
||||||
|
`已把赛事日程改成「${scheduleSummary(config)}」`,
|
||||||
|
'大屏立刻生效;配置随赛事库保存,刷新与重启都不会丢',
|
||||||
|
)
|
||||||
|
} catch (caught) {
|
||||||
|
// 服务端原文(条数超限 / 字段过长 / 权限不足)必须让操作员看见,不能吞成一句「保存失败」。
|
||||||
|
toast.error('保存赛事日程失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
scheduleSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 实例的显示名:有屏号用屏号,局域网浏览器没有屏号,退回来源 IP,都用不上才退回实例 id。
|
* 实例的显示名:有屏号用屏号,局域网浏览器没有屏号,退回来源 IP,都用不上才退回实例 id。
|
||||||
* 只用于提示语,表格里始终显示完整 id。
|
* 只用于提示语,表格里始终显示完整 id。
|
||||||
@@ -947,6 +1108,246 @@ async function send(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
赛事日程侧边栏:手填日程 + 总开关 + 每块屏三态覆盖。
|
||||||
|
保存走 adminApi.displayCommand(display:command 的 schedule 动作,录入员及以上),
|
||||||
|
见 saveSchedule —— 千万别改成 settingsApi.save(那是 admin-only)。
|
||||||
|
排序只有上移 / 下移两个按钮,不引入任何拖拽:本页已有的测试明令禁止
|
||||||
|
pointerdown / mousedown / touchstart 监听(按下的那一刻重排会吞掉 click)。
|
||||||
|
-->
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">赛事日程</span>
|
||||||
|
<span class="mx-badge" :class="scheduleVisible ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||||
|
{{ scheduleRows.length }} 条 · 默认{{ scheduleVisible ? '开启' : '关闭' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p v-if="scheduleGateReason !== null" class="notice">{{ scheduleGateReason }}</p>
|
||||||
|
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact sched-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sched-cell-time">时间</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>备注</th>
|
||||||
|
<th class="sched-cell-done">已完成</th>
|
||||||
|
<th class="right sched-cell-actions">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in scheduleRows" :key="row.key">
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="row.time"
|
||||||
|
class="mx-cell-input"
|
||||||
|
:class="{ 'is-invalid': scheduleErrors.rows[row.key]?.time }"
|
||||||
|
type="text"
|
||||||
|
:maxlength="SCHEDULE_TIME_MAX"
|
||||||
|
placeholder="08:30"
|
||||||
|
:disabled="scheduleLocked"
|
||||||
|
/>
|
||||||
|
<span v-if="scheduleErrors.rows[row.key]?.time" class="row-error">
|
||||||
|
{{ scheduleErrors.rows[row.key]?.time }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="row.title"
|
||||||
|
class="mx-cell-input"
|
||||||
|
:class="{ 'is-invalid': scheduleErrors.rows[row.key]?.title }"
|
||||||
|
type="text"
|
||||||
|
:maxlength="SCHEDULE_TITLE_MAX"
|
||||||
|
placeholder="例如 男子 100 米决赛"
|
||||||
|
:disabled="scheduleLocked"
|
||||||
|
/>
|
||||||
|
<span v-if="scheduleErrors.rows[row.key]?.title" class="row-error">
|
||||||
|
{{ scheduleErrors.rows[row.key]?.title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="row.note"
|
||||||
|
class="mx-cell-input"
|
||||||
|
:class="{ 'is-invalid': scheduleErrors.rows[row.key]?.note }"
|
||||||
|
type="text"
|
||||||
|
:maxlength="SCHEDULE_NOTE_MAX"
|
||||||
|
placeholder="检录 08:00(可留空)"
|
||||||
|
:disabled="scheduleLocked"
|
||||||
|
/>
|
||||||
|
<span v-if="scheduleErrors.rows[row.key]?.note" class="row-error">
|
||||||
|
{{ scheduleErrors.rows[row.key]?.note }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="sched-cell-done">
|
||||||
|
<label class="mx-switch sched-done-switch">
|
||||||
|
<input v-model="row.done" type="checkbox" :disabled="scheduleLocked" />
|
||||||
|
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="sched-cell-actions">
|
||||||
|
<div class="sched-actions">
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="index === 0 || scheduleLocked"
|
||||||
|
title="与上一条交换顺序"
|
||||||
|
@click="moveRow(index, -1)"
|
||||||
|
>
|
||||||
|
上移
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="index === scheduleRows.length - 1 || scheduleLocked"
|
||||||
|
title="与下一条交换顺序"
|
||||||
|
@click="moveRow(index, 1)"
|
||||||
|
>
|
||||||
|
下移
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm danger-text"
|
||||||
|
type="button"
|
||||||
|
:disabled="scheduleLocked"
|
||||||
|
@click="removeScheduleRow(row.key)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="scheduleRows.length === 0">
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="mx-empty">
|
||||||
|
<div class="mx-empty-title">还没有日程条目</div>
|
||||||
|
<div class="mx-empty-desc">点下面的「新增一条」手填时间与标题;不填就没有侧边栏内容</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sched-foot-row">
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="scheduleLocked || scheduleRows.length >= SCHEDULE_MAX_ITEMS"
|
||||||
|
:title="`最多 ${SCHEDULE_MAX_ITEMS} 条`"
|
||||||
|
@click="addScheduleRow"
|
||||||
|
>
|
||||||
|
<MxIcon name="entry" :size="15" /> 新增一条
|
||||||
|
</button>
|
||||||
|
<span class="sched-count">共 {{ scheduleRows.length }} / {{ SCHEDULE_MAX_ITEMS }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-for="message in scheduleFormErrors" :key="message" class="mx-helper mx-helper-error sched-form-error">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="sched-switches">
|
||||||
|
<div class="sched-switch-field">
|
||||||
|
<span class="mx-label">侧边栏默认开启</span>
|
||||||
|
<span class="sched-switch-row">
|
||||||
|
<label class="mx-switch">
|
||||||
|
<input v-model="scheduleVisible" type="checkbox" :disabled="scheduleLocked" />
|
||||||
|
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<span class="sched-switch-text">{{ scheduleVisible ? '开(所有大屏默认显示)' : '关(所有大屏默认隐藏)' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="sched-switch-field">
|
||||||
|
<span class="mx-label">备注上屏</span>
|
||||||
|
<span class="sched-switch-row">
|
||||||
|
<label class="mx-switch">
|
||||||
|
<input v-model="scheduleShowNote" type="checkbox" :disabled="scheduleLocked" />
|
||||||
|
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<span class="sched-switch-text">{{ scheduleShowNote ? '显示备注' : '只显示时间与标题' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sched-geometry">
|
||||||
|
<label class="sched-geometry-field">
|
||||||
|
<span class="mx-label">位置</span>
|
||||||
|
<select v-model="scheduleSide" class="mx-cell-input sched-side-select" :disabled="scheduleLocked">
|
||||||
|
<option v-for="option in SCHEDULE_SIDE_OPTIONS" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="sched-geometry-field">
|
||||||
|
<span class="mx-label">宽度</span>
|
||||||
|
<input
|
||||||
|
v-model.number="scheduleWidth"
|
||||||
|
class="mx-cell-input sched-width-input"
|
||||||
|
type="number"
|
||||||
|
:min="SCHEDULE_WIDTH_MIN"
|
||||||
|
:max="SCHEDULE_WIDTH_MAX"
|
||||||
|
:disabled="scheduleLocked"
|
||||||
|
/>
|
||||||
|
<span class="sched-switch-text">%</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
宽度是占整屏的百分比({{ SCHEDULE_WIDTH_MIN }}–{{ SCHEDULE_WIDTH_MAX }})。大屏上是一块
|
||||||
|
<strong>深色半透明</strong>面板叠在当前页之上:不改当前页、不停轮播,明细与全屏公告期间自动收起。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="sched-overrides">
|
||||||
|
<div class="sched-overrides-title">每块屏单独设置(覆盖上面的总开关)</div>
|
||||||
|
<p v-if="scheduleTargets.length === 0" class="display-note">
|
||||||
|
还没有大屏上报,暂时没有可单独设置的设备;新设备接上后回到这里即可。
|
||||||
|
</p>
|
||||||
|
<div v-for="instance in scheduleTargets" :key="instance.id" class="sched-override-row">
|
||||||
|
<div class="sched-override-name" :title="instanceTooltip(instance)">
|
||||||
|
<span class="instance-name">{{ instanceDisplayName(instance) }}</span>
|
||||||
|
<span v-if="instanceIdNote(instance) !== null" class="instance-id-note">{{ instanceIdNote(instance) }}</span>
|
||||||
|
<span class="mx-badge" :class="instance.online ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||||
|
{{ instance.online ? '在线' : '离线' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
class="mx-cell-input sched-override-select"
|
||||||
|
:value="overrideOf(scheduleOverrides, instance.id)"
|
||||||
|
:disabled="scheduleLocked"
|
||||||
|
:aria-label="`${instanceDisplayName(instance)} 的侧边栏设置`"
|
||||||
|
@change="pickScheduleOverride(instance.id, $event)"
|
||||||
|
>
|
||||||
|
<option v-for="option in SCHEDULE_OVERRIDE_OPTIONS" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p v-if="scheduleHiddenOverrides > 0" class="display-note">
|
||||||
|
另有 {{ scheduleHiddenOverrides }} 条单屏设置对应的设备已不在列表里:设置会照原样保留并随本次保存一起提交。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="announce-foot">
|
||||||
|
<span class="sched-save-note">保存 = 整份替换并立刻推给所有大屏</span>
|
||||||
|
<div class="announce-actions">
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="scheduleLocked"
|
||||||
|
:title="scheduleGateReason ?? '保存后大屏立刻生效'"
|
||||||
|
@click="saveSchedule"
|
||||||
|
>
|
||||||
|
<MxIcon name="check" :size="15" />
|
||||||
|
{{ scheduleSaving ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
条目顺序就是大屏上从上到下的顺序(手填时间不参与排序);勾「已完成」的条目在大屏上淡化保留。
|
||||||
|
每次保存都会让全场大屏重新同步一次,属低频动作:开赛前配一次、现场偶尔勾一下完成。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="mx-card">
|
<section class="mx-card">
|
||||||
<div class="mx-card-head">
|
<div class="mx-card-head">
|
||||||
<span class="mx-card-title">实时赛况</span>
|
<span class="mx-card-title">实时赛况</span>
|
||||||
@@ -1741,6 +2142,140 @@ async function send(
|
|||||||
color: var(--mx-t3);
|
color: var(--mx-t3);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
/* ── 赛事日程卡片 ─────────────────────────────────────────────────────────── */
|
||||||
|
/* 时间列很窄(「08:30」四个字符),标题 / 备注吃掉剩余宽度。 */
|
||||||
|
.sched-cell-time {
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
.sched-cell-done {
|
||||||
|
width: 76px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 操作列定宽 + 不换行:三个按钮(上移 / 下移 / 删除)挤在 auto 布局的窄格里会各自换行,
|
||||||
|
* 一行的按钮竖着摞成一列,整张表被撑到 100px 以上的行高(真 Chromium 里量到过)。
|
||||||
|
*/
|
||||||
|
.sched-cell-actions {
|
||||||
|
width: 176px;
|
||||||
|
min-width: 176px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sched-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
/* 行内红字:与 baseData 各表同一套写法(11px、紧贴字段下方)。 */
|
||||||
|
.row-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.danger-text {
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.sched-foot-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.sched-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
/* 与行无关的校验红字(条数上限、宽度越界),和「新增一条」那一行拉开一点。 */
|
||||||
|
.sched-form-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.sched-switches {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--mx-border);
|
||||||
|
}
|
||||||
|
.sched-switch-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.sched-switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sched-switch-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
/* 位置与宽度:两个窄控件并排,窄屏下换行。 */
|
||||||
|
.sched-geometry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sched-geometry-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sched-side-select {
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
.sched-width-input {
|
||||||
|
width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* 每块屏的三态覆盖:一行一块屏,名字左、下拉右。 */
|
||||||
|
.sched-overrides {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--mx-border);
|
||||||
|
}
|
||||||
|
.sched-overrides-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.sched-override-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
}
|
||||||
|
.sched-override-row + .sched-override-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.sched-override-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
/* 下拉是定宽的:三态文案长度差得多,跟随内容会让整列在切换时抖动。 */
|
||||||
|
.sched-override-select {
|
||||||
|
width: 132px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.sched-save-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.display-grid {
|
.display-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import type { DisplayCommand, ScheduleItem, ScheduleOverride, ScheduleSidebarConfig } from '@/shared'
|
||||||
|
import type { DisplayInstance } from '@/shared/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「赛事日程」卡片的纯逻辑。
|
||||||
|
*
|
||||||
|
* 抽出来是因为组件在仓库里跑不起来(没有 jsdom):默认值、本地校验、上下移的边界、
|
||||||
|
* 三态覆盖的读写(切回「跟随总开关」必须真把键删掉)、请求体组装——这几件事写错的后果
|
||||||
|
* 都不是编译期能发现的:要么现场存进去一份服务端不认的形状,要么列表里留下
|
||||||
|
* `{"<实例 id>": "inherit"}` 这种「看着是默认、实际多一条」的垃圾,要么上移按到第一条上
|
||||||
|
* 把顺序悄悄改乱。
|
||||||
|
*
|
||||||
|
* 校验口径与服务端(`realtime/protocol.go` + `dto/settings.go`)逐条对齐:条数 ≤ 40、
|
||||||
|
* time ≤ 20 / title ≤ 40 / note ≤ 60(**按字符**,不是 UTF-16 码元)、宽度 15–40。
|
||||||
|
* 本地先拦一道是为了「标红并不提交」,而不是等服务端回 400 再让人猜哪一条不对。
|
||||||
|
*
|
||||||
|
* ⚠️ 保存走 `POST /api/display/command` 的 `schedule` 动作(录入员及以上),
|
||||||
|
* **不能走 `PUT /api/settings`** —— 那条是 admin-only,会把「录入员也能切」这条需求打死。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 条目数上限(与服务端同一口径)。 */
|
||||||
|
export const SCHEDULE_MAX_ITEMS = 40
|
||||||
|
export const SCHEDULE_TIME_MAX = 20
|
||||||
|
export const SCHEDULE_TITLE_MAX = 40
|
||||||
|
export const SCHEDULE_NOTE_MAX = 60
|
||||||
|
export const SCHEDULE_WIDTH_MIN = 15
|
||||||
|
export const SCHEDULE_WIDTH_MAX = 40
|
||||||
|
|
||||||
|
/** 位置选项;文案只在这里维护一处,模板与提示语都用它。 */
|
||||||
|
export const SCHEDULE_SIDE_OPTIONS: ReadonlyArray<{ value: 'left' | 'right'; label: string }> = [
|
||||||
|
{ value: 'left', label: '左侧' },
|
||||||
|
{ value: 'right', label: '右侧' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 单屏覆盖的三态选项。顺序固定:默认项在最前,现场一眼看到的是「没特殊设置」。 */
|
||||||
|
export const SCHEDULE_OVERRIDE_OPTIONS: ReadonlyArray<{ value: ScheduleOverride; label: string }> = [
|
||||||
|
{ value: 'inherit', label: '跟随总开关' },
|
||||||
|
{ value: 'on', label: '强制开' },
|
||||||
|
{ value: 'off', label: '强制关' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 读不到配置时用的那一份:侧边栏关闭、右侧 22%、备注上屏。 */
|
||||||
|
export function defaultScheduleConfig(): ScheduleSidebarConfig {
|
||||||
|
return { items: [], visible: false, side: 'right', widthPercent: 22, showNote: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按**字符**数长度:中文一个字算一个,emoji 也算一个。
|
||||||
|
*
|
||||||
|
* 不能直接用 `text.length`(UTF-16 码元数):一个 emoji 在它眼里是 2,服务端按 rune 算是 1,
|
||||||
|
* 于是本地会把「服务端明明收得下」的标题标红。两边口径必须一致。
|
||||||
|
*/
|
||||||
|
export function charCount(text: string): number {
|
||||||
|
return [...text].length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宽度夹到 15–40;非数字退回默认值(解析层兜底用,本地校验仍然拦越界)。 */
|
||||||
|
export function clampWidthPercent(value: unknown): number {
|
||||||
|
const base = defaultScheduleConfig().widthPercent
|
||||||
|
const width = typeof value === 'number' ? value : Number.NaN
|
||||||
|
if (!Number.isFinite(width)) return base
|
||||||
|
return Math.min(SCHEDULE_WIDTH_MAX, Math.max(SCHEDULE_WIDTH_MIN, width))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItem(raw: unknown): ScheduleItem | null {
|
||||||
|
if (raw === null || typeof raw !== 'object') return null
|
||||||
|
const item = raw as Partial<ScheduleItem>
|
||||||
|
// 标题是列表主行,没有标题的条目在大屏上就是一条空白线;服务端不会下发这种数据,
|
||||||
|
// 真碰上了(手工改库 / 老版本写的坏 JSON)就当这条不存在,而不是渲染一条空行。
|
||||||
|
if (typeof item.title !== 'string') return null
|
||||||
|
const out: ScheduleItem = {
|
||||||
|
time: typeof item.time === 'string' ? item.time : '',
|
||||||
|
title: item.title,
|
||||||
|
done: item.done === true,
|
||||||
|
}
|
||||||
|
if (typeof item.note === 'string' && item.note !== '') out.note = item.note
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOverrides(raw: unknown): Record<string, ScheduleOverride> {
|
||||||
|
if (raw === null || typeof raw !== 'object') return {}
|
||||||
|
const out: Record<string, ScheduleOverride> = {}
|
||||||
|
for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
// `inherit` 是「没有这条设置」,落库时不该留着键(否则每次保存都在长垃圾)。
|
||||||
|
if (value === 'on' || value === 'off') out[id] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把服务端下发的那份配置收成界面能用的形状。
|
||||||
|
*
|
||||||
|
* 老服务端没有这个键(`undefined`)、坏 JSON(不是对象)、缺字段都能收下:一律按默认值补,
|
||||||
|
* 绝不抛异常——管理面板读设置失败会把整页的公告 / 实时赛况一起拖下水。
|
||||||
|
*/
|
||||||
|
export function normalizeScheduleConfig(raw: ScheduleSidebarConfig | null | undefined): ScheduleSidebarConfig {
|
||||||
|
const base = defaultScheduleConfig()
|
||||||
|
if (raw === null || raw === undefined || typeof raw !== 'object') return base
|
||||||
|
const items = Array.isArray(raw.items)
|
||||||
|
? raw.items
|
||||||
|
.slice(0, SCHEDULE_MAX_ITEMS)
|
||||||
|
.map(normalizeItem)
|
||||||
|
.filter((item): item is ScheduleItem => item !== null)
|
||||||
|
: []
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
visible: raw.visible === true,
|
||||||
|
side: raw.side === 'left' ? 'left' : 'right',
|
||||||
|
widthPercent: clampWidthPercent(raw.widthPercent),
|
||||||
|
showNote: typeof raw.showNote === 'boolean' ? raw.showNote : base.showNote,
|
||||||
|
instanceOverrides: normalizeOverrides(raw.instanceOverrides),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 行编辑 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表格里的一行。
|
||||||
|
*
|
||||||
|
* `key` 是行标识(v-for 的 key 与行内红字的下标都是它),**不是**数组下标:
|
||||||
|
* 上下移 / 删行之后下标会整体挪位,拿下标当 key 会让红字跟错行(改完 A 行、红字跑到 B 行)。
|
||||||
|
*/
|
||||||
|
export interface ScheduleRow {
|
||||||
|
key: number
|
||||||
|
time: string
|
||||||
|
title: string
|
||||||
|
note: string
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置 → 行;`keyOf` 由页面给(页面持有一个单调递增的计数器,保证新增 / 载入都不撞 key)。 */
|
||||||
|
export function rowsFromSchedule(
|
||||||
|
config: ScheduleSidebarConfig,
|
||||||
|
keyOf: (index: number) => number = (index) => index + 1,
|
||||||
|
): ScheduleRow[] {
|
||||||
|
return config.items.map((item, index) => ({
|
||||||
|
key: keyOf(index),
|
||||||
|
time: item.time,
|
||||||
|
title: item.title,
|
||||||
|
note: item.note ?? '',
|
||||||
|
done: item.done === true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增的空行;`done` 默认未完成(刚排上的日程当然是没开始的)。 */
|
||||||
|
export function emptyScheduleRow(key: number): ScheduleRow {
|
||||||
|
return { key, time: '', title: '', note: '', done: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上移 / 下移一行。
|
||||||
|
*
|
||||||
|
* 越界(第一条上移、最后一条下移)**原样返回同一个数组引用**:页面把结果写回 ref 时,
|
||||||
|
* Vue 靠引用判断有没有变,返回一个新数组会让「点了没反应」也触发一次重渲染;
|
||||||
|
* 而且这几条边界在测试里就是靠 `===` 钉住的。
|
||||||
|
* 真正移动时返回**新数组**(不改原数组),原地改动会让 Vue 的依赖追踪漏掉这次变更。
|
||||||
|
*/
|
||||||
|
export function moveScheduleRow(rows: ScheduleRow[], index: number, delta: number): ScheduleRow[] {
|
||||||
|
const target = index + delta
|
||||||
|
if (index < 0 || index >= rows.length) return rows
|
||||||
|
if (target < 0 || target >= rows.length) return rows
|
||||||
|
const next = [...rows]
|
||||||
|
const current = next[index]
|
||||||
|
const swap = next[target]
|
||||||
|
if (current === undefined || swap === undefined) return rows
|
||||||
|
next[index] = swap
|
||||||
|
next[target] = current
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 本地校验 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 一行的行内红字;与字段一一对应,缺的字段不出现。 */
|
||||||
|
export interface ScheduleRowError {
|
||||||
|
time?: string
|
||||||
|
title?: string
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleErrors {
|
||||||
|
/** 行内错误,按行 key 索引。 */
|
||||||
|
rows: Record<number, ScheduleRowError>
|
||||||
|
/** 与具体行无关的问题(条数上限、宽度越界),按顺序列在卡片底部。 */
|
||||||
|
form: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyScheduleErrors(): ScheduleErrors {
|
||||||
|
return { rows: {}, form: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 有没有任何一条不合规;保存前的唯一判据(不合规就标红、不提交,不等服务端 400)。 */
|
||||||
|
export function hasScheduleErrors(errors: ScheduleErrors): boolean {
|
||||||
|
return errors.form.length > 0 || Object.keys(errors.rows).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宽度文案:非法输入(空 / 非数字 / 越界)都要报同一句,别让人猜是哪一种。 */
|
||||||
|
export const SCHEDULE_WIDTH_ERROR = `宽度为 ${SCHEDULE_WIDTH_MIN}–${SCHEDULE_WIDTH_MAX} 之间的数字`
|
||||||
|
|
||||||
|
export function validateSchedule(rows: ScheduleRow[], widthPercent: number | string): ScheduleErrors {
|
||||||
|
const errors = emptyScheduleErrors()
|
||||||
|
if (rows.length > SCHEDULE_MAX_ITEMS) {
|
||||||
|
errors.form.push(`最多 ${SCHEDULE_MAX_ITEMS} 条日程(当前 ${rows.length} 条)`)
|
||||||
|
}
|
||||||
|
// 空串(输入框被清空)与越界走同一句:`Number('')` 是 0,会被下面的范围判定拦下。
|
||||||
|
const width = typeof widthPercent === 'number' ? widthPercent : Number(String(widthPercent).trim())
|
||||||
|
if (!Number.isFinite(width) || width < SCHEDULE_WIDTH_MIN || width > SCHEDULE_WIDTH_MAX) {
|
||||||
|
errors.form.push(SCHEDULE_WIDTH_ERROR)
|
||||||
|
}
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const rowError: ScheduleRowError = {}
|
||||||
|
const title = row.title.trim()
|
||||||
|
const time = row.time.trim()
|
||||||
|
const note = row.note.trim()
|
||||||
|
if (title === '') rowError.title = '标题必填'
|
||||||
|
else if (charCount(title) > SCHEDULE_TITLE_MAX) rowError.title = `标题最多 ${SCHEDULE_TITLE_MAX} 字`
|
||||||
|
// 时间与备注都可空(时间可以是「上午」「待定」,甚至先留空回头再填),只卡长度。
|
||||||
|
if (charCount(time) > SCHEDULE_TIME_MAX) rowError.time = `时间最多 ${SCHEDULE_TIME_MAX} 字`
|
||||||
|
if (charCount(note) > SCHEDULE_NOTE_MAX) rowError.note = `备注最多 ${SCHEDULE_NOTE_MAX} 字`
|
||||||
|
if (Object.keys(rowError).length > 0) errors.rows[row.key] = rowError
|
||||||
|
})
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 组装 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 行 → 条目:首尾空白在这里去掉(粘贴进来的时间几乎都带空格),备注为空时整键不出现。 */
|
||||||
|
export function itemFromRow(row: ScheduleRow): ScheduleItem {
|
||||||
|
const item: ScheduleItem = { time: row.time.trim(), title: row.title.trim(), done: row.done }
|
||||||
|
const note = row.note.trim()
|
||||||
|
if (note !== '') item.note = note
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleFormState {
|
||||||
|
visible: boolean
|
||||||
|
side: 'left' | 'right'
|
||||||
|
widthPercent: number
|
||||||
|
showNote: boolean
|
||||||
|
instanceOverrides: Record<string, ScheduleOverride>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行 + 开关 → 整份配置(**整份替换**:客户端手上就有整份,服务端不做局部合并)。
|
||||||
|
*
|
||||||
|
* 条目的顺序就是大屏上从上到下的顺序,所以这里绝不做任何排序 —— 手填的时间是自由文本
|
||||||
|
* (「上午」「待定」),排一次就是把用户亲手排的顺序改掉。
|
||||||
|
*/
|
||||||
|
export function scheduleConfigFrom(rows: ScheduleRow[], form: ScheduleFormState): ScheduleSidebarConfig {
|
||||||
|
return {
|
||||||
|
items: rows.map(itemFromRow),
|
||||||
|
visible: form.visible,
|
||||||
|
side: form.side,
|
||||||
|
widthPercent: form.widthPercent,
|
||||||
|
showNote: form.showNote,
|
||||||
|
instanceOverrides: { ...form.instanceOverrides },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存请求体:整份配置 + `targets: []`(空 = 广播给所有大屏,与大屏公告同一条路)。 */
|
||||||
|
export function scheduleCommand(config: ScheduleSidebarConfig): DisplayCommand {
|
||||||
|
return { action: 'schedule', payload: { schedule: config }, targets: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 单屏三态覆盖 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 某块屏当前的三态;没有这条键(绝大多数情况)就是「跟随总开关」。 */
|
||||||
|
export function overrideOf(
|
||||||
|
overrides: Record<string, ScheduleOverride> | undefined,
|
||||||
|
instanceId: string,
|
||||||
|
): ScheduleOverride {
|
||||||
|
const value = overrides?.[instanceId]
|
||||||
|
return value === 'on' || value === 'off' ? value : 'inherit'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入某块屏的三态。
|
||||||
|
*
|
||||||
|
* 切回 `inherit` 时**真的删掉那个键**(不是写一个 `'inherit'` 进去):服务端把它当
|
||||||
|
* 「没有这条设置」,留着键只会让 `instanceOverrides` 越存越长(上限 50 条),
|
||||||
|
* 最后表现为「明明都在跟总开关,却报超过上限」。
|
||||||
|
* 返回新对象,不动传进来的那份(Vue 的依赖追踪靠引用变化)。
|
||||||
|
*/
|
||||||
|
export function withOverride(
|
||||||
|
overrides: Record<string, ScheduleOverride>,
|
||||||
|
instanceId: string,
|
||||||
|
value: ScheduleOverride,
|
||||||
|
): Record<string, ScheduleOverride> {
|
||||||
|
const next = { ...overrides }
|
||||||
|
if (value === 'inherit') delete next[instanceId]
|
||||||
|
else next[instanceId] = value
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片上要列哪几块屏:**在线实例全列**,另外把「离线但身上挂着非 inherit 覆盖」的也列上。
|
||||||
|
*
|
||||||
|
* 后者不为 0 时,操作员在这张卡片上就再也改不动它了——现场是「那块平板今天没开,
|
||||||
|
* 但我先把它的强制关撤掉」,列表里没有它就等于这个设置只能靠改库去掉。
|
||||||
|
*/
|
||||||
|
export function overrideTargets(
|
||||||
|
instances: DisplayInstance[],
|
||||||
|
overrides: Record<string, ScheduleOverride>,
|
||||||
|
): DisplayInstance[] {
|
||||||
|
return instances.filter((instance) => instance.online || overrideOf(overrides, instance.id) !== 'inherit')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表里根本找不到的覆盖条数(实例早已从注册表消失)。
|
||||||
|
*
|
||||||
|
* 这些设置**照样原样提交**(那是别的代理的键,我们没有资格替它删),只是在卡片上说明一句,
|
||||||
|
* 免得操作员以为「列表里没有 = 已经清掉了」。
|
||||||
|
*/
|
||||||
|
export function hiddenOverrideCount(
|
||||||
|
instances: DisplayInstance[],
|
||||||
|
overrides: Record<string, ScheduleOverride>,
|
||||||
|
): number {
|
||||||
|
const known = new Set(instances.map((instance) => instance.id))
|
||||||
|
return Object.keys(overrides).filter((id) => overrideOf(overrides, id) !== 'inherit' && !known.has(id)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 提示语 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 保存成功那句「已把 X 改成 Y」里的 Y。 */
|
||||||
|
export function scheduleSummary(config: ScheduleSidebarConfig): string {
|
||||||
|
const side = SCHEDULE_SIDE_OPTIONS.find((option) => option.value === config.side)?.label ?? config.side
|
||||||
|
return `共 ${config.items.length} 条 · 默认${config.visible ? '开启' : '关闭'} · ${side} ${config.widthPercent}%`
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「赛事日程」卡片的接线。
|
||||||
|
*
|
||||||
|
* 页面级测试在仓库里跑不起来(没有 jsdom / @vue/test-utils),所以组件那半边退化成源码断言。
|
||||||
|
* 这里钉的四件事,每一件写错的后果都在现场:
|
||||||
|
* 1. 卡片必须挂在**公告卡之后**(左列从上到下的顺序就是现场的操作顺序);
|
||||||
|
* 2. 保存必须走 `adminApi.displayCommand` —— 走 `settingsApi.save` 是 admin-only,
|
||||||
|
* 会把「录入员及以上可改」这条需求当场变成必然 403(本页的实时赛况就是因为这个才置灰的);
|
||||||
|
* 3. 排序只有上移 / 下移两个按钮:**绝不能挂 pointerdown / mousedown / touchstart**
|
||||||
|
* (与 lan-qr-toggle 同一口径 —— 按下的那一刻重排会吞掉随后的 click);
|
||||||
|
* 4. 单屏覆盖的键是**实例 id**,不是屏号 / 下标 / 实例对象上的新字段
|
||||||
|
* (往 GET /api/display/instances 的结构里塞字段会被 deepEqual 拦住)。
|
||||||
|
*/
|
||||||
|
const PAGE = readFileSync(new URL('../pages/DisplayPage.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
/** 卡片那一段模板:从卡片上方的说明注释起,到它自己的 `</section>` 止。 */
|
||||||
|
function cardBlock(): string {
|
||||||
|
const start = PAGE.indexOf('赛事日程侧边栏:手填日程 + 总开关 + 每块屏三态覆盖')
|
||||||
|
const end = PAGE.indexOf('</section>', start)
|
||||||
|
return start < 0 || end < 0 ? '' : PAGE.slice(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片那一段脚本:从分区注释起,到下一位邻居(实例显示名)止。 */
|
||||||
|
function scriptBlock(): string {
|
||||||
|
const start = PAGE.indexOf('// ── 赛事日程侧边栏')
|
||||||
|
const end = PAGE.indexOf('function instanceLabel', start)
|
||||||
|
return start < 0 || end < 0 ? '' : PAGE.slice(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('卡片挂在公告卡之后、实时赛况之前', () => {
|
||||||
|
const announce = PAGE.indexOf('<span class="mx-card-title">公告</span>')
|
||||||
|
const schedule = PAGE.indexOf('<span class="mx-card-title">赛事日程</span>')
|
||||||
|
const live = PAGE.indexOf('<span class="mx-card-title">实时赛况</span>')
|
||||||
|
assert.notEqual(announce, -1)
|
||||||
|
assert.notEqual(schedule, -1)
|
||||||
|
assert.ok(announce < schedule, '赛事日程卡片必须在公告卡之后')
|
||||||
|
assert.ok(schedule < live, '赛事日程卡片必须在实时赛况卡之前')
|
||||||
|
// 是一张独立卡片,不是塞进公告卡的角落里。
|
||||||
|
const block = cardBlock()
|
||||||
|
assert.notEqual(block, '', '找不到卡片模板')
|
||||||
|
assert.match(block, /<section class="mx-card">/)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 注释里提到这些名字(正是为了说明「别改成那样」)不算接线,统计前先剥掉。 */
|
||||||
|
function stripComments(text: string): string {
|
||||||
|
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('保存走 adminApi.displayCommand,脚本里不出现 settingsApi.save', () => {
|
||||||
|
const script = scriptBlock()
|
||||||
|
assert.notEqual(script, '', '找不到卡片的脚本块')
|
||||||
|
assert.match(script, /await adminApi\.displayCommand\(scheduleCommand\(config\)\)/)
|
||||||
|
// 这是本卡片最容易写错的一处:PUT /api/settings 是 admin-only,录入员会吃 403。
|
||||||
|
// 注释里那张对照表不算数(它存在的意义就是提醒别改成那条路),剥掉注释再看。
|
||||||
|
assert.doesNotMatch(stripComments(script), /settingsApi/)
|
||||||
|
// 请求体由纯函数组装(整份配置 + targets: []),页面不自己拼 payload。
|
||||||
|
assert.match(script, /scheduleCommand/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有 pointerdown / mousedown / touchstart 监听(排序只用按钮)', () => {
|
||||||
|
// 与 admin/tests/lan-qr-toggle.test.ts 同一条护栏:按下的那一刻重排会吞掉 click。
|
||||||
|
assert.doesNotMatch(PAGE, /addEventListener\('(pointerdown|mousedown|mouseup|touchstart)'/)
|
||||||
|
const block = cardBlock()
|
||||||
|
// 卡片里也没有 `draggable` 这类拖拽痕迹。
|
||||||
|
assert.doesNotMatch(block, /draggable|dragstart|drop\.|@drop/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('条目表格:五列齐全,行内编辑绑在行上', () => {
|
||||||
|
const block = cardBlock()
|
||||||
|
for (const head of ['时间', '标题', '备注', '已完成', '操作']) {
|
||||||
|
assert.ok(block.includes(`>${head}</th>`) || block.includes(`>${head}<`) || block.includes(head), `表头缺少「${head}」`)
|
||||||
|
}
|
||||||
|
assert.match(block, /v-for="\(row, index\) in scheduleRows" :key="row\.key"/)
|
||||||
|
assert.match(block, /v-model="row\.time"/)
|
||||||
|
assert.match(block, /v-model="row\.title"/)
|
||||||
|
assert.match(block, /v-model="row\.note"/)
|
||||||
|
assert.match(block, /v-model="row\.done"/)
|
||||||
|
// 长度上限用与服务端同一批常量,不写死数字。
|
||||||
|
assert.match(block, /:maxlength="SCHEDULE_TIME_MAX"/)
|
||||||
|
assert.match(block, /:maxlength="SCHEDULE_TITLE_MAX"/)
|
||||||
|
assert.match(block, /:maxlength="SCHEDULE_NOTE_MAX"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('操作列:上移 / 下移 / 删除都绑上了,边界行禁用', () => {
|
||||||
|
const block = cardBlock()
|
||||||
|
assert.match(block, /@click="moveRow\(index, -1\)"/)
|
||||||
|
assert.match(block, /@click="moveRow\(index, 1\)"/)
|
||||||
|
assert.match(block, /:disabled="index === 0 \|\| scheduleLocked"/)
|
||||||
|
assert.match(block, /:disabled="index === scheduleRows\.length - 1 \|\| scheduleLocked"/)
|
||||||
|
assert.match(block, /@click="removeScheduleRow\(row\.key\)"/)
|
||||||
|
assert.match(block, /@click="addScheduleRow"/)
|
||||||
|
// 行内按钮走的是纯函数,页面里没有第二套交换逻辑。
|
||||||
|
const script = scriptBlock()
|
||||||
|
assert.match(script, /scheduleRows\.value = moveScheduleRow\(scheduleRows\.value, index, delta\)/)
|
||||||
|
assert.match(script, /scheduleRows\.value = \[\.\.\.scheduleRows\.value, emptyScheduleRow\(takeScheduleKey\(\)\)\]/)
|
||||||
|
assert.match(script, /scheduleRows\.value\.filter\(\(row\) => row\.key !== key\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('总开关与备注开关用 mx-switch,且都绑在卡片状态上', () => {
|
||||||
|
const block = cardBlock()
|
||||||
|
assert.match(block, /<label class="mx-switch">\s*<input v-model="scheduleVisible" type="checkbox"/)
|
||||||
|
assert.match(block, /<input v-model="scheduleShowNote" type="checkbox"/)
|
||||||
|
// 已完成也是一样的开关(每行一个),所以卡片里至少三个。
|
||||||
|
assert.ok((block.match(/class="mx-switch/g) ?? []).length >= 3, '卡片里的开关数量不对')
|
||||||
|
// 禁用一律走同一个 gate(未登录 / 未连接 / 提交中)。
|
||||||
|
assert.match(block, /:disabled="scheduleLocked"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('位置与宽度:选项来自常量表,宽度给 15–40 的边界', () => {
|
||||||
|
const block = cardBlock()
|
||||||
|
assert.match(block, /v-for="option in SCHEDULE_SIDE_OPTIONS"/)
|
||||||
|
assert.match(block, /v-model="scheduleSide"/)
|
||||||
|
assert.match(block, /v-model\.number="scheduleWidth"/)
|
||||||
|
assert.match(block, /:min="SCHEDULE_WIDTH_MIN"/)
|
||||||
|
assert.match(block, /:max="SCHEDULE_WIDTH_MAX"/)
|
||||||
|
// 卡片上要说清这是深色半透明面板(具体 CSS 在大屏端,管理端只标注形态)。
|
||||||
|
assert.match(block, /深色半透明/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('单屏三态覆盖:键是实例 id,三态下拉绑在覆盖状态上', () => {
|
||||||
|
const block = cardBlock()
|
||||||
|
assert.match(block, /v-for="instance in scheduleTargets" :key="instance\.id"/)
|
||||||
|
assert.match(block, /v-for="option in SCHEDULE_OVERRIDE_OPTIONS"/)
|
||||||
|
assert.match(block, /:value="overrideOf\(scheduleOverrides, instance\.id\)"/)
|
||||||
|
assert.match(block, /@change="pickScheduleOverride\(instance\.id, \$event\)"/)
|
||||||
|
// 标题用既有的显示名逻辑(别名 → 设备 → 实例 id),不自己再拼一套。
|
||||||
|
assert.match(block, /instanceDisplayName\(instance\)/)
|
||||||
|
assert.match(block, /instanceIdNote\(instance\)/)
|
||||||
|
// 键必须是实例 id:屏号(同一台机器可能没有屏号)与下标都不行。
|
||||||
|
assert.doesNotMatch(block, /scheduleOverrides\[instance\.screen\]/)
|
||||||
|
assert.doesNotMatch(block, /scheduleOverrides\[index\]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('权限:整卡只读时给中文原因,保存按钮也按同一句话置灰', () => {
|
||||||
|
const block = cardBlock()
|
||||||
|
assert.match(block, /<p v-if="scheduleGateReason !== null" class="notice">\{\{ scheduleGateReason \}\}<\/p>/)
|
||||||
|
assert.match(block, /:title="scheduleGateReason \?\? '保存后大屏立刻生效'"/)
|
||||||
|
assert.match(block, /:disabled="scheduleLocked"/)
|
||||||
|
const script = scriptBlock()
|
||||||
|
// 程序化触发也要有一句明确的权限回答,而不是去等 403。
|
||||||
|
assert.match(script, /toast\.error\('当前不可保存赛事日程'/)
|
||||||
|
// 只读判定跟 canWrite 走(录入员及以上),不再额外要管理员。
|
||||||
|
assert.match(script, /scheduleGateReason = computed\(\(\) =>\s*canWrite\.value \? null/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('本地校验:不合规就标红并不提交,红字来自纯函数', () => {
|
||||||
|
const script = scriptBlock()
|
||||||
|
assert.match(script, /const errors = validateSchedule\(scheduleRows\.value, scheduleWidth\.value\)/)
|
||||||
|
assert.match(script, /if \(hasScheduleErrors\(errors\)\) \{/)
|
||||||
|
// 拦下之后必须先 return —— 少这一句就是「标了红却照样提交」。
|
||||||
|
const guard = /if \(hasScheduleErrors\(errors\)\) \{[\s\S]*?\n \}/.exec(script)?.[0] ?? ''
|
||||||
|
assert.match(guard, /toast\.error\('赛事日程校验未通过'/)
|
||||||
|
assert.match(guard, /return/)
|
||||||
|
const block = cardBlock()
|
||||||
|
assert.match(block, /scheduleErrors\.rows\[row\.key\]\?\.title/)
|
||||||
|
assert.match(block, /v-for="message in scheduleFormErrors"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('保存成功 / 失败:成功那句是「已把 X 改成 Y」,失败把服务端原文交给 toast', () => {
|
||||||
|
const script = scriptBlock()
|
||||||
|
assert.match(script, /toast\.success\(\s*`已把赛事日程改成「\$\{scheduleSummary\(config\)\}」`/)
|
||||||
|
assert.match(script, /toast\.error\('保存赛事日程失败', caught instanceof Error \? caught\.message : null\)/)
|
||||||
|
// 示例数据模式下没有真服务端,不能谎报「已保存」。
|
||||||
|
assert.match(script, /toast\.warning\('示例数据模式未真正保存'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('数据来源:读设置时把服务端那份日程同步进卡片,读不到时用默认值', () => {
|
||||||
|
const apply = /function applyScheduleConfig[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
|
||||||
|
assert.notEqual(apply, '', '找不到 applyScheduleConfig')
|
||||||
|
assert.match(apply, /normalizeScheduleConfig\(raw\)/)
|
||||||
|
assert.match(apply, /rowsFromSchedule\(config, \(\) => takeScheduleKey\(\)\)/)
|
||||||
|
assert.match(apply, /scheduleWidth\.value = config\.widthPercent/)
|
||||||
|
assert.match(apply, /scheduleOverrides\.value = \{ \.\.\.\(config\.instanceOverrides \?\? \{\}\) \}/)
|
||||||
|
// 挂载时先用已有的那份设置垫上,refreshSettings 随后核对。
|
||||||
|
assert.match(PAGE, /applyScheduleConfig\(data\.settings\?\.display\?\.schedule \?\? null\)/)
|
||||||
|
assert.match(PAGE, /applyScheduleConfig\(settings\.display\?\.schedule \?\? null\)/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import type { ScheduleSidebarConfig } from '@/shared'
|
||||||
|
import type { DisplayInstance } from '@/shared/api'
|
||||||
|
import {
|
||||||
|
SCHEDULE_MAX_ITEMS,
|
||||||
|
SCHEDULE_NOTE_MAX,
|
||||||
|
SCHEDULE_TIME_MAX,
|
||||||
|
SCHEDULE_TITLE_MAX,
|
||||||
|
SCHEDULE_WIDTH_ERROR,
|
||||||
|
SCHEDULE_WIDTH_MAX,
|
||||||
|
SCHEDULE_WIDTH_MIN,
|
||||||
|
charCount,
|
||||||
|
clampWidthPercent,
|
||||||
|
defaultScheduleConfig,
|
||||||
|
emptyScheduleErrors,
|
||||||
|
emptyScheduleRow,
|
||||||
|
hasScheduleErrors,
|
||||||
|
hiddenOverrideCount,
|
||||||
|
itemFromRow,
|
||||||
|
moveScheduleRow,
|
||||||
|
normalizeScheduleConfig,
|
||||||
|
overrideOf,
|
||||||
|
overrideTargets,
|
||||||
|
rowsFromSchedule,
|
||||||
|
scheduleCommand,
|
||||||
|
scheduleConfigFrom,
|
||||||
|
scheduleSummary,
|
||||||
|
validateSchedule,
|
||||||
|
withOverride,
|
||||||
|
type ScheduleRow,
|
||||||
|
} from '../pages/scheduleEditor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「赛事日程」卡片的纯逻辑。
|
||||||
|
*
|
||||||
|
* 组件跑不进测试(仓库里没有 jsdom),所以这一层是整个卡片的唯一可测面:默认值、
|
||||||
|
* 本地校验、上下移边界、三态覆盖的读写、请求体形状。这几件事写错的后果都在现场才看得见
|
||||||
|
* —— 最典型的是「切回跟随总开关时留了一个 `inherit` 垃圾键」,它会让 `instanceOverrides`
|
||||||
|
* 一次一次攒满 50 条上限,最后表现成「明明什么都没改,却报超过上限」。
|
||||||
|
*/
|
||||||
|
|
||||||
|
function row(partial: Partial<ScheduleRow> = {}): ScheduleRow {
|
||||||
|
return { key: 1, time: '', title: '开幕式', note: '', done: false, ...partial }
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(partial: Partial<ScheduleSidebarConfig> = {}): ScheduleSidebarConfig {
|
||||||
|
return { ...defaultScheduleConfig(), ...partial }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 默认值 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('默认配置:空列表、关闭、右侧 22%、备注上屏', () => {
|
||||||
|
assert.deepEqual(defaultScheduleConfig(), {
|
||||||
|
items: [],
|
||||||
|
visible: false,
|
||||||
|
side: 'right',
|
||||||
|
widthPercent: 22,
|
||||||
|
showNote: true,
|
||||||
|
})
|
||||||
|
// 每次都是新对象:页面把它当初始值存进 ref,共享同一个引用会让两处改动互相串。
|
||||||
|
assert.notEqual(defaultScheduleConfig(), defaultScheduleConfig())
|
||||||
|
// 默认不带单屏覆盖这个键:`inherit` 就是「没有这条设置」,不该在默认值里预先摆一堆空键。
|
||||||
|
assert.equal('instanceOverrides' in defaultScheduleConfig(), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没配置(老服务端 / 坏 JSON)时收下默认值,不抛异常', () => {
|
||||||
|
assert.deepEqual(normalizeScheduleConfig(null), defaultScheduleConfig())
|
||||||
|
assert.deepEqual(normalizeScheduleConfig(undefined), defaultScheduleConfig())
|
||||||
|
// 服务端把 schedule 写成了字符串 / 数字这类坏形状。
|
||||||
|
assert.deepEqual(normalizeScheduleConfig('nope' as unknown as ScheduleSidebarConfig), defaultScheduleConfig())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('解析服务端配置:坏字段逐条兜底,不整份丢掉', () => {
|
||||||
|
const parsed = normalizeScheduleConfig({
|
||||||
|
items: [
|
||||||
|
{ time: '08:30', title: '开幕式', done: true },
|
||||||
|
{ time: 5, title: '男子 100 米决赛', note: '检录 08:00' },
|
||||||
|
// 没有标题的条目在大屏上就是一条空白线:整条不要。
|
||||||
|
{ time: '09:00' },
|
||||||
|
'nope',
|
||||||
|
],
|
||||||
|
visible: true,
|
||||||
|
side: 'left',
|
||||||
|
widthPercent: 33,
|
||||||
|
showNote: false,
|
||||||
|
instanceOverrides: { 'display-a1b2c3d4': 'off', 'display-dead': 'inherit', 'display-x': 'nope' },
|
||||||
|
} as unknown as ScheduleSidebarConfig)
|
||||||
|
|
||||||
|
assert.equal(parsed.visible, true)
|
||||||
|
assert.equal(parsed.side, 'left')
|
||||||
|
assert.equal(parsed.widthPercent, 33)
|
||||||
|
assert.equal(parsed.showNote, false)
|
||||||
|
assert.deepEqual(parsed.items, [
|
||||||
|
{ time: '08:30', title: '开幕式', done: true },
|
||||||
|
// time 不是字符串 → 空串;done 缺省 → false;note 照收。
|
||||||
|
{ time: '', title: '男子 100 米决赛', done: false, note: '检录 08:00' },
|
||||||
|
])
|
||||||
|
// `inherit` 与不认识的值都不留键:它们等于「没有这条设置」。
|
||||||
|
assert.deepEqual(parsed.instanceOverrides, { 'display-a1b2c3d4': 'off' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('解析时越界宽度夹到 15–40,方向未知退回右侧', () => {
|
||||||
|
assert.equal(normalizeScheduleConfig(config({ widthPercent: 4 })).widthPercent, SCHEDULE_WIDTH_MIN)
|
||||||
|
assert.equal(normalizeScheduleConfig(config({ widthPercent: 90 })).widthPercent, SCHEDULE_WIDTH_MAX)
|
||||||
|
assert.equal(normalizeScheduleConfig(config({ widthPercent: Number.NaN })).widthPercent, 22)
|
||||||
|
assert.equal(normalizeScheduleConfig(config({ side: 'top' as 'left' })).side, 'right')
|
||||||
|
// 条目超过上限只留前 40 条(服务端同样只认 40 条)。
|
||||||
|
const many = Array.from({ length: 45 }, (_, index) => ({ time: '', title: `第 ${index + 1} 项`, done: false }))
|
||||||
|
assert.equal(normalizeScheduleConfig(config({ items: many })).items.length, SCHEDULE_MAX_ITEMS)
|
||||||
|
assert.equal(clampWidthPercent('22'), 22)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 行 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('配置 → 行:key 由页面给,note / done 缺省归一', () => {
|
||||||
|
const rows = rowsFromSchedule(config({ items: [{ time: '上午', title: '开幕式' }] }))
|
||||||
|
assert.deepEqual(rows, [{ key: 1, time: '上午', title: '开幕式', note: '', done: false }])
|
||||||
|
// 页面持有一个单调递增的计数器,保证删一行再加一行不撞 key。
|
||||||
|
assert.deepEqual(rowsFromSchedule(config({ items: [{ time: '', title: 'A' }, { time: '', title: 'B' }] }), (i) => 100 + i), [
|
||||||
|
{ key: 100, time: '', title: 'A', note: '', done: false },
|
||||||
|
{ key: 101, time: '', title: 'B', note: '', done: false },
|
||||||
|
])
|
||||||
|
assert.deepEqual(rowsFromSchedule(defaultScheduleConfig()), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('新增的空行:默认未完成,四个字段都空', () => {
|
||||||
|
assert.deepEqual(emptyScheduleRow(7), { key: 7, time: '', title: '', note: '', done: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('上移 / 下移:中间两行交换,且不改动原数组', () => {
|
||||||
|
const rows = [row({ key: 1, title: 'A' }), row({ key: 2, title: 'B' }), row({ key: 3, title: 'C' })]
|
||||||
|
const moved = moveScheduleRow(rows, 1, -1)
|
||||||
|
assert.deepEqual(moved.map((item) => item.title), ['B', 'A', 'C'])
|
||||||
|
// 原地改动会让 Vue 的依赖追踪漏掉这次变更,所以必须返回新数组。
|
||||||
|
assert.notEqual(moved, rows)
|
||||||
|
assert.deepEqual(rows.map((item) => item.title), ['A', 'B', 'C'])
|
||||||
|
|
||||||
|
const down = moveScheduleRow(rows, 1, 1)
|
||||||
|
assert.deepEqual(down.map((item) => item.title), ['A', 'C', 'B'])
|
||||||
|
assert.deepEqual(rows.map((item) => item.title), ['A', 'B', 'C'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('上移 / 下移的边界:第一条上移、最后一条下移原样返回同一个引用', () => {
|
||||||
|
const rows = [row({ key: 1, title: 'A' }), row({ key: 2, title: 'B' })]
|
||||||
|
// 同引用 = 页面写回 ref 时不会触发任何重渲染,「点了没反应」才是正确表现。
|
||||||
|
assert.equal(moveScheduleRow(rows, 0, -1), rows)
|
||||||
|
assert.equal(moveScheduleRow(rows, 1, 1), rows)
|
||||||
|
// 越界下标(程序化调用)也不许造出一个新数组。
|
||||||
|
assert.equal(moveScheduleRow(rows, -1, 1), rows)
|
||||||
|
assert.equal(moveScheduleRow(rows, 9, -1), rows)
|
||||||
|
assert.equal(moveScheduleRow([], 0, 1).length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 本地校验 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('长度按字符算:中文与 emoji 各算一个(与服务端的 rune 计数一致)', () => {
|
||||||
|
assert.equal(charCount('男子 100 米'), 8)
|
||||||
|
// 带连接符的 emoji 由 4 个码点组成(人 + ZWJ + 性别符号 + 变体选择符),服务端按 rune 也数 4;
|
||||||
|
// 用 `.length`(UTF-16 码元)会数成 5,本地就会把服务端明明收得下的标题标红。
|
||||||
|
assert.equal(charCount('🏃♂️'), 4)
|
||||||
|
assert.equal('🏃♂️'.length, 5)
|
||||||
|
// 40 个中文字刚好合格,41 个超标 —— 用 .length 的话中文不会差,但 emoji 会误报。
|
||||||
|
const title = '决'.repeat(SCHEDULE_TITLE_MAX)
|
||||||
|
assert.equal(validateSchedule([row({ title })], 22).rows[1], undefined)
|
||||||
|
assert.equal(
|
||||||
|
validateSchedule([row({ title: `${title}决` })], 22).rows[1]?.title,
|
||||||
|
`标题最多 ${SCHEDULE_TITLE_MAX} 字`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('校验:合法的行与宽度通过,没有任何红字', () => {
|
||||||
|
const rows = [
|
||||||
|
row({ key: 1, time: '08:30', title: '开幕式', note: '检录 08:00' }),
|
||||||
|
row({ key: 2, time: '上午', title: '男子 100 米决赛', done: true }),
|
||||||
|
// 时间可以先留空回头再填(大屏上就是一格空白时间)。
|
||||||
|
row({ key: 3, time: '', title: '待定项目' }),
|
||||||
|
]
|
||||||
|
const errors = validateSchedule(rows, 22)
|
||||||
|
assert.deepEqual(errors, emptyScheduleErrors())
|
||||||
|
assert.equal(hasScheduleErrors(errors), false)
|
||||||
|
// 边界值也算合法。
|
||||||
|
assert.equal(hasScheduleErrors(validateSchedule(rows, SCHEDULE_WIDTH_MIN)), false)
|
||||||
|
assert.equal(hasScheduleErrors(validateSchedule(rows, SCHEDULE_WIDTH_MAX)), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('校验:条数超过 40 时给与行无关的红字', () => {
|
||||||
|
const rows = Array.from({ length: SCHEDULE_MAX_ITEMS + 1 }, (_, index) => row({ key: index + 1, title: `第 ${index + 1} 项` }))
|
||||||
|
const errors = validateSchedule(rows, 22)
|
||||||
|
assert.deepEqual(errors.form, [`最多 ${SCHEDULE_MAX_ITEMS} 条日程(当前 ${SCHEDULE_MAX_ITEMS + 1} 条)`])
|
||||||
|
assert.equal(hasScheduleErrors(errors), true)
|
||||||
|
// 正好 40 条不报。
|
||||||
|
assert.deepEqual(validateSchedule(rows.slice(0, SCHEDULE_MAX_ITEMS), 22).form, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('校验:标题必填、各字段超长各给各的行内红字', () => {
|
||||||
|
const rows = [
|
||||||
|
row({ key: 1, title: ' ' }),
|
||||||
|
row({ key: 2, title: 'A', time: 'x'.repeat(SCHEDULE_TIME_MAX + 1) }),
|
||||||
|
row({ key: 3, title: 'B', note: 'x'.repeat(SCHEDULE_NOTE_MAX + 1) }),
|
||||||
|
]
|
||||||
|
const errors = validateSchedule(rows, 22)
|
||||||
|
assert.equal(errors.rows[1]?.title, '标题必填')
|
||||||
|
assert.equal(errors.rows[2]?.time, `时间最多 ${SCHEDULE_TIME_MAX} 字`)
|
||||||
|
assert.equal(errors.rows[3]?.note, `备注最多 ${SCHEDULE_NOTE_MAX} 字`)
|
||||||
|
// 同一行的另一格不会被连坐标红。
|
||||||
|
assert.equal(errors.rows[2]?.title, undefined)
|
||||||
|
assert.deepEqual(errors.form, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('校验:宽度非数字 / 越界都是同一句红字(空输入框也算)', () => {
|
||||||
|
for (const width of ['', 14, SCHEDULE_WIDTH_MAX + 1, Number.NaN, 'abc']) {
|
||||||
|
const errors = validateSchedule([row()], width)
|
||||||
|
assert.deepEqual(errors.form, [SCHEDULE_WIDTH_ERROR], `宽度 ${String(width)} 应当被拦下`)
|
||||||
|
}
|
||||||
|
assert.deepEqual(validateSchedule([row()], 15).form, [])
|
||||||
|
assert.deepEqual(validateSchedule([row()], 40).form, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空列表 + 合法宽度是合法配置(等于「大屏上没有侧边栏内容」)', () => {
|
||||||
|
assert.equal(hasScheduleErrors(validateSchedule([], 22)), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 三态覆盖 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('三态:没有键就是「跟随总开关」', () => {
|
||||||
|
assert.equal(overrideOf({}, 'display-a'), 'inherit')
|
||||||
|
assert.equal(overrideOf(undefined, 'display-a'), 'inherit')
|
||||||
|
// 坏值(手工改库 / 老版本写进去的)也当跟随,不能把大屏切成一个不认识的状态。
|
||||||
|
assert.equal(overrideOf({ 'display-a': 'maybe' as 'on' }, 'display-a'), 'inherit')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('三态:切 on / off 写进键,切回 inherit 时真删掉那个键', () => {
|
||||||
|
const base: Record<string, 'inherit' | 'on' | 'off'> = { 'display-a': 'off' }
|
||||||
|
const on = withOverride(base, 'display-a', 'on')
|
||||||
|
assert.deepEqual(on, { 'display-a': 'on' })
|
||||||
|
// 新对象:原地改会让 Vue 的依赖追踪漏掉这次变更。
|
||||||
|
assert.notEqual(on, base)
|
||||||
|
assert.deepEqual(base, { 'display-a': 'off' })
|
||||||
|
|
||||||
|
const backToInherit = withOverride(on, 'display-a', 'inherit')
|
||||||
|
assert.deepEqual(backToInherit, {})
|
||||||
|
// 关键:键必须**消失**,不能留一个 `'inherit'` 的垃圾(攒满 50 条上限就会开始被服务端拒绝)。
|
||||||
|
assert.equal('display-a' in backToInherit, false)
|
||||||
|
// 字符串化之后也看不到那个键:服务端与快照比对的是 JSON。
|
||||||
|
assert.equal(JSON.stringify(backToInherit), '{}')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('三态:多块屏互不影响', () => {
|
||||||
|
let overrides: Record<string, 'inherit' | 'on' | 'off'> = {}
|
||||||
|
overrides = withOverride(overrides, 'display-a', 'on')
|
||||||
|
overrides = withOverride(overrides, 'display-b', 'off')
|
||||||
|
overrides = withOverride(overrides, 'display-a', 'inherit')
|
||||||
|
assert.deepEqual(overrides, { 'display-b': 'off' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const ONLINE: DisplayInstance = {
|
||||||
|
id: 'display-a',
|
||||||
|
online: true,
|
||||||
|
page: 'standings',
|
||||||
|
lastHeartbeat: '2026-09-17T10:00:00Z',
|
||||||
|
connectedAt: '2026-09-17T09:00:00Z',
|
||||||
|
remoteAddr: '192.168.1.23:52341',
|
||||||
|
ackVersion: 7,
|
||||||
|
slow: false,
|
||||||
|
screen: '',
|
||||||
|
}
|
||||||
|
const OFFLINE_PLAIN: DisplayInstance = { ...ONLINE, id: 'display-b', online: false }
|
||||||
|
const OFFLINE_WITH_OVERRIDE: DisplayInstance = { ...ONLINE, id: 'display-c', online: false }
|
||||||
|
|
||||||
|
test('要列哪几块屏:在线全列,离线但挂着覆盖的也列(否则那个设置再也改不回来)', () => {
|
||||||
|
const overrides = { 'display-c': 'off' as const, 'display-gone': 'on' as const }
|
||||||
|
const targets = overrideTargets([ONLINE, OFFLINE_PLAIN, OFFLINE_WITH_OVERRIDE], overrides)
|
||||||
|
assert.deepEqual(targets.map((item) => item.id), ['display-a', 'display-c'])
|
||||||
|
assert.equal(hiddenOverrideCount([ONLINE, OFFLINE_PLAIN, OFFLINE_WITH_OVERRIDE], overrides), 1)
|
||||||
|
assert.equal(hiddenOverrideCount([ONLINE], {}), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 组装与请求体 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('行 → 条目:首尾空白去掉,空备注整键不出现', () => {
|
||||||
|
assert.deepEqual(itemFromRow(row({ time: ' 08:30 ', title: ' 开幕式 ', note: ' ', done: true })), {
|
||||||
|
time: '08:30',
|
||||||
|
title: '开幕式',
|
||||||
|
done: true,
|
||||||
|
})
|
||||||
|
assert.deepEqual(itemFromRow(row({ note: ' 检录 08:00 ' })), {
|
||||||
|
time: '',
|
||||||
|
title: '开幕式',
|
||||||
|
note: '检录 08:00',
|
||||||
|
done: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('组装整份配置:顺序照行顺序,不做任何排序', () => {
|
||||||
|
const rows = [row({ key: 1, time: '待定', title: '颁奖' }), row({ key: 2, time: '08:30', title: '开幕式' })]
|
||||||
|
const config = scheduleConfigFrom(rows, {
|
||||||
|
visible: true,
|
||||||
|
side: 'left',
|
||||||
|
widthPercent: 30,
|
||||||
|
showNote: false,
|
||||||
|
instanceOverrides: { 'display-a': 'on' },
|
||||||
|
})
|
||||||
|
assert.deepEqual(config, {
|
||||||
|
items: [
|
||||||
|
{ time: '待定', title: '颁奖', done: false },
|
||||||
|
{ time: '08:30', title: '开幕式', done: false },
|
||||||
|
],
|
||||||
|
visible: true,
|
||||||
|
side: 'left',
|
||||||
|
widthPercent: 30,
|
||||||
|
showNote: false,
|
||||||
|
instanceOverrides: { 'display-a': 'on' },
|
||||||
|
})
|
||||||
|
// 手填时间不参与排序:调换两行的顺序就是调换数组顺序,没有任何隐式重排。
|
||||||
|
const swapped = scheduleConfigFrom([rows[1]!, rows[0]!], {
|
||||||
|
visible: true,
|
||||||
|
side: 'left',
|
||||||
|
widthPercent: 30,
|
||||||
|
showNote: false,
|
||||||
|
instanceOverrides: {},
|
||||||
|
})
|
||||||
|
assert.deepEqual(swapped.items.map((item) => item.title), ['开幕式', '颁奖'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('请求体:schedule 动作 + 整份配置 + targets 为空(广播给所有大屏)', () => {
|
||||||
|
const config = scheduleConfigFrom([row({ time: '08:30', title: '开幕式' })], {
|
||||||
|
visible: true,
|
||||||
|
side: 'right',
|
||||||
|
widthPercent: 22,
|
||||||
|
showNote: true,
|
||||||
|
instanceOverrides: { 'display-a': 'off' },
|
||||||
|
})
|
||||||
|
const command = scheduleCommand(config)
|
||||||
|
assert.equal(command.action, 'schedule')
|
||||||
|
assert.deepEqual(command.targets, [])
|
||||||
|
// payload 里带的就是**整份**配置(服务端不做局部合并),且是同一个对象、不复制。
|
||||||
|
assert.equal(command.payload.schedule, config)
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(command)), {
|
||||||
|
action: 'schedule',
|
||||||
|
payload: {
|
||||||
|
schedule: {
|
||||||
|
items: [{ time: '08:30', title: '开幕式', done: false }],
|
||||||
|
visible: true,
|
||||||
|
side: 'right',
|
||||||
|
widthPercent: 22,
|
||||||
|
showNote: true,
|
||||||
|
instanceOverrides: { 'display-a': 'off' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
targets: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('保存成功那句「已把 X 改成 Y」:条数、默认开关、位置与宽度', () => {
|
||||||
|
assert.equal(scheduleSummary(config({ items: [{ time: '', title: 'A' }], visible: true, side: 'left', widthPercent: 30 })), '共 1 条 · 默认开启 · 左侧 30%')
|
||||||
|
assert.equal(scheduleSummary(defaultScheduleConfig()), '共 0 条 · 默认关闭 · 右侧 22%')
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import { useScoreboard } from './useScoreboard'
|
|||||||
import { useStageScale } from './useStageScale'
|
import { useStageScale } from './useStageScale'
|
||||||
import { WINDOWED_TITLEBAR_HEIGHT, isWindowed } from './windowed'
|
import { WINDOWED_TITLEBAR_HEIGHT, isWindowed } from './windowed'
|
||||||
import DetailOverlay from './components/DetailOverlay.vue'
|
import DetailOverlay from './components/DetailOverlay.vue'
|
||||||
|
import ScheduleSidebar from './components/ScheduleSidebar.vue'
|
||||||
import TickerBar from './components/TickerBar.vue'
|
import TickerBar from './components/TickerBar.vue'
|
||||||
import TopBar from './components/TopBar.vue'
|
import TopBar from './components/TopBar.vue'
|
||||||
import WindowedTitleBar from './components/WindowedTitleBar.vue'
|
import WindowedTitleBar from './components/WindowedTitleBar.vue'
|
||||||
@@ -113,6 +114,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailOverlay />
|
<DetailOverlay />
|
||||||
|
<!-- 日程侧边栏叠在**所有页面与明细覆盖层之上**,但它只读 detail(明细开着就先收起),
|
||||||
|
不参与明细状态机:goto 关明细、明细停轮播都跟它无关。 -->
|
||||||
|
<ScheduleSidebar />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useScoreboard } from '../useScoreboard'
|
||||||
|
import { instanceIdStorage, resolveDisplayIdentity } from '../instanceId'
|
||||||
|
import { textEm } from '../fitFont'
|
||||||
|
import {
|
||||||
|
SIDEBAR_AXIS_COLUMN_PX,
|
||||||
|
SIDEBAR_AXIS_GAP_PX,
|
||||||
|
SIDEBAR_PADDING_X_PX,
|
||||||
|
SIDEBAR_PADDING_Y_PX,
|
||||||
|
currentScheduleIndex,
|
||||||
|
overallProgress,
|
||||||
|
resolveSidebarConfig,
|
||||||
|
scheduleLayout,
|
||||||
|
scheduleNodeState,
|
||||||
|
sidebarGeometry,
|
||||||
|
sidebarSuppressed,
|
||||||
|
truncateScheduleText,
|
||||||
|
visibleScheduleWindow,
|
||||||
|
} from '../schedulePanel'
|
||||||
|
import type { ScheduleNodeState } from '../schedulePanel'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 赛事日程侧边栏:叠在**任意当前页**之上的半透明毛玻璃面板。
|
||||||
|
*
|
||||||
|
* 三条边界(写在这里免得后来者顺手改坏):
|
||||||
|
* 1. **独立于 `detail` 状态机**:只读 detail 决定「要不要先收起来」,绝不开关明细、
|
||||||
|
* 也绝不碰轮播——管理端发 `goto` 会关明细、明细覆盖会停轮播,而侧边栏两件都不做。
|
||||||
|
* 2. **关着的时候一件 DOM 都不留**:`v-if` 在根上,关闭时连一个空 div 都没有,
|
||||||
|
* 大屏逐字节等同于没有这个功能(入场动效因此走 CSS animation 而不是 <Transition>,
|
||||||
|
* 后者会留下注释占位节点)。
|
||||||
|
* 3. **尺寸全是舞台设计坐标**:面板挂在 `.stage` 里、跟着 `transform: scale()` 一起缩放,
|
||||||
|
* 所以字号 / 间距都不能按视口算(见 schedulePanel.ts 顶上那段「双重缩放」)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { schedule, detail, page, announcement, options } = useScoreboard()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本机实例 id:与大屏上报给服务端的**是同一把钥匙**(Console 窗口的 `instance`、
|
||||||
|
* 只带屏号的 `screen`、局域网浏览器本机稳定 id 都在 displayKey 里汇合)。
|
||||||
|
* 拿不到(localStorage 不可用且 URL 没带)就是空串 —— 此时单屏覆盖一律按「跟随总开关」处理。
|
||||||
|
*/
|
||||||
|
const instanceId = computed(
|
||||||
|
() =>
|
||||||
|
resolveDisplayIdentity(
|
||||||
|
{ instance: options.value.instance, screen: options.value.screen },
|
||||||
|
instanceIdStorage(),
|
||||||
|
).displayKey ?? '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolved = computed(() => resolveSidebarConfig(schedule.value, instanceId.value))
|
||||||
|
const side = computed(() => resolved.value.side)
|
||||||
|
|
||||||
|
/** 全屏公告 = 公告状态机在跑、且页面就在公告页(计时结束退回普通页后它自己就解除了)。 */
|
||||||
|
const announceFullscreen = computed(() => announcement.value !== null && page.value === 'announcement')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明细覆盖层 / 全屏公告期间自动收起(定稿口径 6)。
|
||||||
|
* 只影响渲染,配置本身一个字节都不动:明细关掉后原样回来。
|
||||||
|
*/
|
||||||
|
const visible = computed(
|
||||||
|
() => resolved.value.enabled && !sidebarSuppressed(detail.value !== null, announceFullscreen.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = computed(() => schedule.value?.items ?? [])
|
||||||
|
const progress = computed(() => overallProgress(items.value))
|
||||||
|
const currentIndex = computed(() => currentScheduleIndex(items.value))
|
||||||
|
|
||||||
|
const geometry = computed(() => sidebarGeometry(resolved.value.widthPercent))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排版决策的输入取**整份列表**(不是当前窗口):窗口随当前项推移而移动,
|
||||||
|
* 若按窗口算,窗口一挪字号就跳一下;按整列表算则全程稳定,代价只是一条特别长的条目
|
||||||
|
* 会让全屏字号小一档——比「翻到哪条字就变多大」强。
|
||||||
|
*/
|
||||||
|
const layout = computed(() =>
|
||||||
|
scheduleLayout({
|
||||||
|
itemCount: items.value.length,
|
||||||
|
availablePx: geometry.value.availablePx,
|
||||||
|
columnPx: geometry.value.columnPx,
|
||||||
|
longestTitleEm: items.value.reduce((max, item) => Math.max(max, textEm(item.title)), 0),
|
||||||
|
longestNoteEm: items.value.reduce((max, item) => Math.max(max, textEm(item.note ?? '')), 0),
|
||||||
|
hasTime: items.value.some((item) => item.time !== ''),
|
||||||
|
showNote: resolved.value.showNote,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const shownWindow = computed(() =>
|
||||||
|
visibleScheduleWindow(items.value, currentIndex.value, layout.value.capacity),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 一条目各行能用的显示宽度(em):标题按它的行数预算、时间与备注各占一行 / 两行。 */
|
||||||
|
const titleBudgetEm = computed(
|
||||||
|
() => (geometry.value.columnPx * layout.value.titleLines) / layout.value.fontPx,
|
||||||
|
)
|
||||||
|
const noteBudgetEm = computed(() =>
|
||||||
|
layout.value.noteLines > 0
|
||||||
|
? (geometry.value.columnPx * layout.value.noteLines) / layout.value.noteFontPx
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
const timeBudgetEm = computed(() => geometry.value.columnPx / layout.value.noteFontPx)
|
||||||
|
|
||||||
|
interface SidebarRow {
|
||||||
|
key: string
|
||||||
|
state: ScheduleNodeState
|
||||||
|
time: string
|
||||||
|
title: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = computed<SidebarRow[]>(() => {
|
||||||
|
const list = items.value
|
||||||
|
const win = shownWindow.value
|
||||||
|
return list.slice(win.start, win.end).map((item, offset) => {
|
||||||
|
const index = win.start + offset
|
||||||
|
return {
|
||||||
|
// 时间 / 标题可以重复(手填数据),下标才是唯一键。
|
||||||
|
key: `${index}`,
|
||||||
|
state: scheduleNodeState(item, index, currentIndex.value),
|
||||||
|
time: truncateScheduleText(item.time, timeBudgetEm.value),
|
||||||
|
title: truncateScheduleText(item.title, titleBudgetEm.value),
|
||||||
|
note: truncateScheduleText(item.note ?? '', noteBudgetEm.value),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const barWidth = computed(() => {
|
||||||
|
const { done, total } = progress.value
|
||||||
|
return total === 0 ? '0%' : `${Math.round((done / total) * 100)}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板几何与字号全部走 CSS 变量,量都来自 schedulePanel.ts —— 单一来源,
|
||||||
|
* 改一个常量不会出现「算的时候按 22%、画的时候按 24%」这种对不上的情况。
|
||||||
|
*/
|
||||||
|
const panelStyle = computed<Record<string, string>>(() => ({
|
||||||
|
'--sb-pad-x': `${SIDEBAR_PADDING_X_PX}px`,
|
||||||
|
'--sb-pad-y': `${SIDEBAR_PADDING_Y_PX}px`,
|
||||||
|
'--sb-axis': `${SIDEBAR_AXIS_COLUMN_PX}px`,
|
||||||
|
'--sb-gap': `${SIDEBAR_AXIS_GAP_PX}px`,
|
||||||
|
'--sb-font': `${layout.value.fontPx}px`,
|
||||||
|
'--sb-note-font': `${layout.value.noteFontPx}px`,
|
||||||
|
'--sb-row-h': `${layout.value.rowHeightPx}px`,
|
||||||
|
'--sb-row-gap': `${layout.value.rowGapPx}px`,
|
||||||
|
width: `${geometry.value.panelPx}px`,
|
||||||
|
[side.value]: '0',
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="visible" class="schedule-layer">
|
||||||
|
<aside
|
||||||
|
class="panel"
|
||||||
|
:class="side"
|
||||||
|
:style="panelStyle"
|
||||||
|
aria-label="赛事日程"
|
||||||
|
data-schedule-sidebar
|
||||||
|
:data-side="side"
|
||||||
|
:data-font-px="layout.fontPx"
|
||||||
|
:data-capacity="layout.capacity"
|
||||||
|
:data-visible-rows="rows.length"
|
||||||
|
:data-available-px="geometry.availablePx"
|
||||||
|
>
|
||||||
|
<header class="head">
|
||||||
|
<div class="head-row">
|
||||||
|
<span class="brand">赛事日程</span>
|
||||||
|
<span class="readout">已完成 {{ progress.done }} / 共 {{ progress.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar"><i :style="{ width: barWidth }" /></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="shownWindow.hiddenBefore > 0" class="more">上方还有 {{ shownWindow.hiddenBefore }} 项</p>
|
||||||
|
|
||||||
|
<ol class="list">
|
||||||
|
<li v-for="row in rows" :key="row.key" class="row" :class="row.state">
|
||||||
|
<span class="axis"><i class="node" /></span>
|
||||||
|
<div class="body">
|
||||||
|
<p v-if="row.time" class="time">{{ row.time }}</p>
|
||||||
|
<p class="title">{{ row.title }}</p>
|
||||||
|
<p v-if="row.note" class="note">{{ row.note }}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p v-if="shownWindow.hiddenAfter > 0" class="more">下方还有 {{ shownWindow.hiddenAfter }} 项</p>
|
||||||
|
<p v-if="rows.length === 0" class="empty">暂无日程安排</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 覆盖层只占位不拦事件:大屏本来就没有交互,pointer-events:none 保证它永远不会挡住当前页。 */
|
||||||
|
.schedule-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: var(--sb-pad-y) var(--sb-pad-x);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
/* 定稿口径 3B:深色半透明 + 毛玻璃。底子足够暗,白字对比度才守得住。 */
|
||||||
|
background: linear-gradient(180deg, rgba(6, 16, 32, 0.72), rgba(4, 11, 24, 0.82));
|
||||||
|
backdrop-filter: blur(18px) saturate(1.15);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(1.15);
|
||||||
|
animation: sb-in 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
.panel.right {
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
--sb-enter-x: 18px;
|
||||||
|
}
|
||||||
|
.panel.left {
|
||||||
|
border-right: 3px solid var(--primary);
|
||||||
|
--sb-enter-x: -18px;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 毛玻璃退化:`backdrop-filter` 不被支持(老浏览器 / 被禁用 / 被省电模式关掉)时,
|
||||||
|
* 半透明底会把身后的榜单穿出来,白字就跟成绩叠在一起看不清。
|
||||||
|
* 这里换成几乎不透明的深底,观感与「有毛玻璃」的暗色更接近,仍然是一块干净的深色面板。
|
||||||
|
*/
|
||||||
|
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||||
|
.panel {
|
||||||
|
background: linear-gradient(180deg, rgba(4, 11, 24, 0.97), rgba(3, 8, 18, 0.98));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
flex: none;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 2px solid var(--primary-line);
|
||||||
|
}
|
||||||
|
/* 表头一行放不下时(窄屏 15%)读数自动折到第二行:两个都 nowrap,
|
||||||
|
宁可多占一行也不把「赛事日程」拆成「赛事日 / 程」。 */
|
||||||
|
.head-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.readout {
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--primary-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
margin-top: 14px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--primary);
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sb-row-gap);
|
||||||
|
margin: 18px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
/* 条目等分整个列表高度:时间轴因此是一条通到底的连续线,也不会在面板底部留一片空。
|
||||||
|
等分的高度一定 ≥ 算出来的 --sb-row-h(scheduleLayout 保证总高不超过可用高度),
|
||||||
|
所以文字永远排得下。 */
|
||||||
|
.row {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sb-axis) 1fr;
|
||||||
|
column-gap: var(--sb-gap);
|
||||||
|
min-height: var(--sb-row-h);
|
||||||
|
/* 行本身**不能** overflow:hidden:轴段要靠负的行距外扩到上下两行之间,
|
||||||
|
裁掉它时间轴就断成一节一节了。文本溢出由 .body 那一层兜住。 */
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.time,
|
||||||
|
.note {
|
||||||
|
font-size: var(--sb-note-font);
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
color: var(--primary-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: var(--sb-font);
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 竖向轴:每一行画自己那一段,段与段之间用负的行距接上,视觉上是一条连续的线。 */
|
||||||
|
.axis {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.axis::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: calc(-1 * var(--sb-row-gap));
|
||||||
|
bottom: calc(-1 * var(--sb-row-gap));
|
||||||
|
width: 3px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
.node {
|
||||||
|
position: relative;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
/* 节点对齐这一条**第一行文字**的中线(时间那行的半高减半个节点),而不是行高的正中:
|
||||||
|
行是等分拉高的,居中会让圆点漂到文字块下面去。 */
|
||||||
|
margin-top: calc((var(--sb-note-font) * 1.3 - 12px) / 2);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
/* 已完成(定稿口径 4A):淡化保留,看得出走过哪些。 */
|
||||||
|
.row.done .axis::before {
|
||||||
|
background: var(--primary-line);
|
||||||
|
}
|
||||||
|
.row.done .node {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: none;
|
||||||
|
background: var(--primary);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
/* 已完成的文字一起淡化(定稿口径 4A):看得出走过哪些,又不跟当前项抢注意力。 */
|
||||||
|
.row.done .title,
|
||||||
|
.row.done .time,
|
||||||
|
.row.done .note {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
/* 当前项:放大 + 光晕,是全屏唯一「跳出来」的节点。 */
|
||||||
|
.row.current .axis::before {
|
||||||
|
background: linear-gradient(180deg, var(--primary) 0%, var(--primary) 50%, rgba(255, 255, 255, 0.14) 50%);
|
||||||
|
}
|
||||||
|
.row.current .node {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: calc((var(--sb-note-font) * 1.3 - 20px) / 2);
|
||||||
|
border: none;
|
||||||
|
background: var(--primary);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 6px var(--primary-flash),
|
||||||
|
0 0 18px var(--primary);
|
||||||
|
}
|
||||||
|
.row.current .title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more,
|
||||||
|
.empty {
|
||||||
|
flex: none;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关掉动效(定稿口径 8):入场不做位移与淡入,直接出现。 */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.panel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.bar i {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes sb-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(var(--sb-enter-x, 18px));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,6 +24,9 @@ import type {
|
|||||||
ResultMode,
|
ResultMode,
|
||||||
ResultStatus,
|
ResultStatus,
|
||||||
ResultType,
|
ResultType,
|
||||||
|
ScheduleItem,
|
||||||
|
ScheduleOverride,
|
||||||
|
ScheduleSidebarConfig,
|
||||||
ScoreChannel,
|
ScoreChannel,
|
||||||
SnapshotMessage,
|
SnapshotMessage,
|
||||||
StandingRow,
|
StandingRow,
|
||||||
@@ -31,6 +34,7 @@ import type {
|
|||||||
TeamBreakdown,
|
TeamBreakdown,
|
||||||
} from '@/shared'
|
} from '@/shared'
|
||||||
import type { LiveEventSource } from './live'
|
import type { LiveEventSource } from './live'
|
||||||
|
import { MAX_SCHEDULE_ITEMS, clampSidebarWidthPercent } from './schedulePanel'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `display.rotateSeconds` 缺配置时的兜底秒数。
|
* `display.rotateSeconds` 缺配置时的兜底秒数。
|
||||||
@@ -464,6 +468,92 @@ export function parseLiveEventIds(value: unknown): number[] {
|
|||||||
return ids
|
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<string, ScheduleOverride> | undefined {
|
||||||
|
if (!isRecord(value)) return undefined
|
||||||
|
const overrides: Record<string, ScheduleOverride> = {}
|
||||||
|
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 {
|
export function parseSettings(source: unknown): DisplaySettings {
|
||||||
const rec = record(source)
|
const rec = record(source)
|
||||||
const showIndividualBoard = bool(
|
const showIndividualBoard = bool(
|
||||||
@@ -485,6 +575,9 @@ export function parseSettings(source: unknown): DisplaySettings {
|
|||||||
LIVE_SOURCES,
|
LIVE_SOURCES,
|
||||||
'auto',
|
'auto',
|
||||||
)
|
)
|
||||||
|
// 赛事日程侧边栏:整份配置(条目 + 开关 + 左右 / 宽度 + 单屏覆盖)由服务端随设置下发,
|
||||||
|
// 这里**只做容错收窄**——坏形状 → null = 没有日程,绝不让它把整页设置解析带崩。
|
||||||
|
const schedule = parseScheduleConfig(settingValue(rec, 'display.schedule', 'display.schedule'))
|
||||||
return {
|
return {
|
||||||
event: {
|
event: {
|
||||||
name: str(settingValue(rec, 'event.name', 'event.name')),
|
name: str(settingValue(rec, 'event.name', 'event.name')),
|
||||||
@@ -508,6 +601,7 @@ export function parseSettings(source: unknown): DisplaySettings {
|
|||||||
fontBody: fontBody === '' ? null : fontBody,
|
fontBody: fontBody === '' ? null : fontBody,
|
||||||
liveEvents,
|
liveEvents,
|
||||||
liveSource,
|
liveSource,
|
||||||
|
schedule,
|
||||||
},
|
},
|
||||||
standings: {
|
standings: {
|
||||||
individualToTeamWeight: num(
|
individualToTeamWeight: num(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
Event,
|
Event,
|
||||||
EventResultGroup,
|
EventResultGroup,
|
||||||
EventResultRow,
|
EventResultRow,
|
||||||
|
ScheduleItem,
|
||||||
SnapshotMessage,
|
SnapshotMessage,
|
||||||
StandingRow,
|
StandingRow,
|
||||||
Team,
|
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 字面量里会被多余属性检查拦下。
|
* 直接写在 SnapshotMessage 字面量里会被多余属性检查拦下。
|
||||||
@@ -289,6 +316,15 @@ const MOCK_SETTINGS: DisplaySettings = {
|
|||||||
// 示例数据里有 3 个已完成项目,取前两个演示双栏分栏。
|
// 示例数据里有 3 个已完成项目,取前两个演示双栏分栏。
|
||||||
liveEvents: [1, 2],
|
liveEvents: [1, 2],
|
||||||
liveSource: 'manual',
|
liveSource: 'manual',
|
||||||
|
// 赛事日程侧边栏:默认右侧 22%,备注上屏。示例数据里直接开着,
|
||||||
|
// 就是为了让「排版 / 三态 / 开窗 / 毛玻璃」在没有后端时也能被看到与验收。
|
||||||
|
schedule: {
|
||||||
|
items: MOCK_SCHEDULE_ITEMS,
|
||||||
|
visible: true,
|
||||||
|
side: 'right',
|
||||||
|
widthPercent: 22,
|
||||||
|
showNote: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
standings: {
|
standings: {
|
||||||
individualToTeamWeight: 1,
|
individualToTeamWeight: 1,
|
||||||
|
|||||||
@@ -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<string, ScheduleOverride> | 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')
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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> = {}): 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), '')
|
||||||
|
})
|
||||||
@@ -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(/<!--[\s\S]*?-->/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('侧边栏挂在 <DetailOverlay /> 之后,且在 .stage 里', () => {
|
||||||
|
const overlay = APP.indexOf('<DetailOverlay />')
|
||||||
|
const sidebar = APP.indexOf('<ScheduleSidebar />')
|
||||||
|
assert.ok(overlay > 0, 'DetailOverlay 还在')
|
||||||
|
assert.ok(sidebar > overlay, '侧边栏必须挂在明细覆盖层之后(覆盖层在最上层)')
|
||||||
|
assert.equal(APP.split('<ScheduleSidebar />').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, /<div v-if="visible" class="schedule-layer">/)
|
||||||
|
assert.doesNotMatch(CODE, /<Transition/, '不用 Transition:它会留下注释占位节点')
|
||||||
|
assert.match(COMPONENT, /animation:\s*sb-in/, '入场动效改走 CSS animation')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('深色半透明 + 毛玻璃,且有 @supports 退化', () => {
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
Event,
|
Event,
|
||||||
EventResultGroup,
|
EventResultGroup,
|
||||||
RealtimeStatus,
|
RealtimeStatus,
|
||||||
|
ScheduleSidebarConfig,
|
||||||
StandingRow,
|
StandingRow,
|
||||||
Team,
|
Team,
|
||||||
} from '@/shared'
|
} from '@/shared'
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
DEFAULT_ROTATE_SECONDS,
|
DEFAULT_ROTATE_SECONDS,
|
||||||
parseAthleteStandings,
|
parseAthleteStandings,
|
||||||
parseLiveFeed,
|
parseLiveFeed,
|
||||||
|
parseScheduleConfig,
|
||||||
parseSettings,
|
parseSettings,
|
||||||
parseSnapshot,
|
parseSnapshot,
|
||||||
parseStandingRow,
|
parseStandingRow,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
parseTeamBreakdown,
|
parseTeamBreakdown,
|
||||||
} from './guards'
|
} from './guards'
|
||||||
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
|
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
|
||||||
|
import { scheduleSignature } from './schedulePanel'
|
||||||
import { entryShareIndex, entrySquadIndex } from './jointTeams'
|
import { entryShareIndex, entrySquadIndex } from './jointTeams'
|
||||||
import type { DisplayTeamShare } from './jointTeams'
|
import type { DisplayTeamShare } from './jointTeams'
|
||||||
import { entryRosterIndex } from './members'
|
import { entryRosterIndex } from './members'
|
||||||
@@ -80,6 +83,17 @@ const options = ref<DisplayUrlOptions>({
|
|||||||
resultsCount: 3,
|
resultsCount: 3,
|
||||||
})
|
})
|
||||||
const detail = ref<DetailOverlayState | null>(null)
|
const detail = ref<DetailOverlayState | null>(null)
|
||||||
|
/**
|
||||||
|
* 赛事日程侧边栏的当前配置(**热开关**)。
|
||||||
|
*
|
||||||
|
* 两个来源:`display:command` 的 schedule 指令(立刻生效)与设置里的 `display.schedule`
|
||||||
|
* (服务端持久值,刷新 / 重启不丢)。两者内容同源,靠指纹去重,见 applyScheduleConfig。
|
||||||
|
*
|
||||||
|
* 与 `detail` 状态机**完全无关**:这里只是一个配置 ref,`goto` 关明细、明细停轮播都不碰它。
|
||||||
|
*/
|
||||||
|
const schedule = ref<ScheduleSidebarConfig | null>(null)
|
||||||
|
/** 上一次应用过的日程配置指纹(初始 '' = 与「没有日程」同源)。 */
|
||||||
|
let seenScheduleSignature = scheduleSignature(null)
|
||||||
const booted = ref(false)
|
const booted = ref(false)
|
||||||
|
|
||||||
/** 公告状态机:全屏退场后正文继续留着,底栏读的就是它。 */
|
/** 公告状态机:全屏退场后正文继续留着,底栏读的就是它。 */
|
||||||
@@ -635,6 +649,13 @@ function handleCommand(command: DisplayCommand): void {
|
|||||||
case 'announce_clear':
|
case 'announce_clear':
|
||||||
announcementStore.clear()
|
announcementStore.clear()
|
||||||
break
|
break
|
||||||
|
case 'schedule': {
|
||||||
|
// 热开关:整份配置即时应用,不等下一次快照(服务端随后也会把持久值推下来,指纹相同不重复应用)。
|
||||||
|
// 解析成 null(坏形状 / 老服务端不带这个字段)一律忽略——一条脏指令不能把已经上屏的日程清掉。
|
||||||
|
const parsed = parseScheduleConfig(command.payload.schedule)
|
||||||
|
if (parsed !== null) applyScheduleConfig(parsed)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'show-detail':
|
case 'show-detail':
|
||||||
void openDetail(command)
|
void openDetail(command)
|
||||||
break
|
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 都要重新挂字体。
|
// 大屏字体随设置变化:快照、缓存、示例数据任何一条路径改动 settings 都要重新挂字体。
|
||||||
watch(
|
watch(
|
||||||
settings,
|
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,
|
authRequired,
|
||||||
options,
|
options,
|
||||||
detail,
|
detail,
|
||||||
|
schedule,
|
||||||
announcement,
|
announcement,
|
||||||
announceText,
|
announceText,
|
||||||
booted,
|
booted,
|
||||||
|
|||||||
@@ -681,6 +681,43 @@ export interface RecordBreakInput {
|
|||||||
* 赛事配置。服务端以 `a.b` 形式存在 settings 表里(如 `display.rotate_seconds`),
|
* 赛事配置。服务端以 `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<string, ScheduleOverride>
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventSettings {
|
export interface EventSettings {
|
||||||
event: {
|
event: {
|
||||||
name: string
|
name: string
|
||||||
@@ -718,6 +755,11 @@ export interface EventSettings {
|
|||||||
* 旧服务端不下发时可选,解析层归一成 auto。
|
* 旧服务端不下发时可选,解析层归一成 auto。
|
||||||
*/
|
*/
|
||||||
liveSource?: string | null
|
liveSource?: string | null
|
||||||
|
/**
|
||||||
|
* 赛事日程侧边栏(手填的日程 + 开关 + 左右与宽度 + 单屏覆盖)。
|
||||||
|
* 旧服务端不下发这个键,所以可选;大屏解析层归一成 null(= 用内置默认:关闭)。
|
||||||
|
*/
|
||||||
|
schedule?: ScheduleSidebarConfig | null
|
||||||
}
|
}
|
||||||
standings: {
|
standings: {
|
||||||
individualToTeamWeight: number
|
individualToTeamWeight: number
|
||||||
@@ -1205,6 +1247,11 @@ export type DisplayAction =
|
|||||||
| 'announce_clear'
|
| 'announce_clear'
|
||||||
| 'show-detail'
|
| 'show-detail'
|
||||||
| 'hide-detail'
|
| 'hide-detail'
|
||||||
|
/**
|
||||||
|
* 保存赛事日程侧边栏(**整份替换**)。与大屏公告同一档:录入员及以上,
|
||||||
|
* 服务端在同一个请求里既转发指令给大屏、也落库到赛事设置(刷新/重启不丢)。
|
||||||
|
*/
|
||||||
|
| 'schedule'
|
||||||
|
|
||||||
export interface DisplayCommandPayload {
|
export interface DisplayCommandPayload {
|
||||||
page?: string
|
page?: string
|
||||||
@@ -1216,6 +1263,8 @@ export interface DisplayCommandPayload {
|
|||||||
title?: string
|
title?: string
|
||||||
/** 明细由管理端算好后随命令下发,大屏只负责显示(所见即所得)。 */
|
/** 明细由管理端算好后随命令下发,大屏只负责显示(所见即所得)。 */
|
||||||
detail?: TeamBreakdown
|
detail?: TeamBreakdown
|
||||||
|
/** 赛事日程侧边栏的整份配置(action = 'schedule' 时必填)。 */
|
||||||
|
schedule?: ScheduleSidebarConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisplayCommand {
|
export interface DisplayCommand {
|
||||||
|
|||||||
Reference in new issue
Block a user