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:
AskaEth committed 2026-09-17 20:04:50 +08:00
1 parent 56ed90ff8b
commit 9c52dc4b38
33 files changed
+4736 -10

No files matched your search

+144
View File
@@ -1,12 +1,16 @@
package httpapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
@@ -318,6 +322,127 @@ func announceFromPayload(payload any) (string, int) {
return text, int(seconds)
}
// scheduleFromPayload 把 schedule 指令的 payload 解成整份日程配置,并做完写入口的校验与归一。
//
// 返回的 error 是给现场看的**中文提示**,调用方按 400 处理。口径:
// - 条目数、文本长度、单屏覆盖条数与取值超限一律**拒绝**,不静默截断 ——
// 悄悄存一半会让人以为配置已经生效;
// - 只有两类按口径归一(夹取 / 回落,没有信息损失):widthPercent 夹到 15–40
// (缺省或非正数回落默认宽度)、side 认不出时回落 right。
//
// 文本一律先过 realtime.CleanUserText(去控制字符 + 去首尾空白,与设备名、大屏别名共用一个收口):
// 一条带换行的标题能把大屏那一列顶歪,而服务端是唯一的收口处。清洗后只判**标题**必填
// (必填口径与管理端 scheduleEditor.ts 的 validateSchedule 逐字一致,那边时间也只卡长度)。
//
// 单屏覆盖的键是实例 id,**不**做控制字符剥离:剥完就不再是那台设备的 id,覆盖会静默落空;
// 含控制字符的键直接判非法,让现场看见。
//
// 两处「缺省不等于零值」在这里收口:showNote 没带就是 true(备注默认上屏)、
// 值为 inherit 的覆盖不进 map(它就是「没有这条设置」)。
func scheduleFromPayload(payload any) (dto.ScheduleConfig, error) {
if payload == nil {
return dto.ScheduleConfig{}, errors.New("schedule 指令缺少 payload:需要整份日程配置")
}
// payload 在请求体里是 any,再走一遍 JSON 才能按结构体类型解出来(类型不对在这里就报错)。
raw, err := json.Marshal(payload)
if err != nil {
return dto.ScheduleConfig{}, errors.New("日程配置无法解析")
}
var cfg dto.ScheduleConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return dto.ScheduleConfig{}, errors.New(
"日程配置格式不正确:payload 应是一个对象(items / visible / side / widthPercent / showNote / instanceOverrides)")
}
// showNote 缺省 = true(备注默认上屏):bool 零值分不出「键没带」与「显式 false」,
// 这里必须回头看一眼原始 JSON,否则漏字段的客户端会把备注悄悄关掉。
showNote := dto.ScheduleShowNote(raw)
if len(cfg.Items) > dto.MaxScheduleItems {
return dto.ScheduleConfig{}, fmt.Errorf("日程条目最多 %d 条(当前 %d 条)",
dto.MaxScheduleItems, len(cfg.Items))
}
items := make([]dto.ScheduleItem, 0, len(cfg.Items))
for i, it := range cfg.Items {
// 序号按**用户看到的顺序**从 1 数起:提示里说「第 3 条」才能对上编辑面板里那一行。
no := i + 1
time := realtime.CleanUserText(it.Time)
title := realtime.CleanUserText(it.Title)
note := realtime.CleanUserText(it.Note)
switch {
// 时间**允许为空**(管理端 `validateSchedule` 同口径,定稿口径是「时间完全手填」):
// 现场常见「先把标题排上、时间回头补」,空的只是不上屏显示时间那一格,不是错误数据。
case utf8.RuneCountInString(time) > dto.MaxScheduleTimeChars:
return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的时间最长 %d 个字符(当前 %d 个)",
no, dto.MaxScheduleTimeChars, utf8.RuneCountInString(time))
case title == "":
return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的标题不能为空", no)
case utf8.RuneCountInString(title) > dto.MaxScheduleTitleChars:
return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的标题最长 %d 个字符(当前 %d 个)",
no, dto.MaxScheduleTitleChars, utf8.RuneCountInString(title))
case utf8.RuneCountInString(note) > dto.MaxScheduleNoteChars:
return dto.ScheduleConfig{}, fmt.Errorf("第 %d 条日程的备注最长 %d 个字符(当前 %d 个)",
no, dto.MaxScheduleNoteChars, utf8.RuneCountInString(note))
}
// 清空的时间原样存空串(不带 omitempty 以外的替换):大屏看到空串就不画时间那一格。
items = append(items, dto.ScheduleItem{Time: time, Title: title, Note: note, Done: it.Done})
}
if len(cfg.InstanceOverrides) > dto.MaxScheduleOverrides {
return dto.ScheduleConfig{}, fmt.Errorf("单屏覆盖最多 %d 条(当前 %d 条)",
dto.MaxScheduleOverrides, len(cfg.InstanceOverrides))
}
// 按 id 排序后再校验:map 的遍历顺序是随机的,不排序的话「先报哪一条」每次都不一样,
// 现场按提示去改的那一条可能下一次就不是它了。
var overrides map[string]string
if len(cfg.InstanceOverrides) > 0 {
ids := make([]string, 0, len(cfg.InstanceOverrides))
for id := range cfg.InstanceOverrides {
ids = append(ids, id)
}
sort.Strings(ids)
overrides = make(map[string]string, len(ids))
for _, id := range ids {
value := cfg.InstanceOverrides[id]
if realtime.CleanUserText(id) != strings.TrimSpace(id) {
return dto.ScheduleConfig{}, fmt.Errorf("单屏覆盖的实例 id %q 含有非法字符", id)
}
key := strings.TrimSpace(id)
if key == "" {
return dto.ScheduleConfig{}, errors.New("单屏覆盖的实例 id 不能为空")
}
if utf8.RuneCountInString(key) > dto.MaxScheduleOverrideKeyChars {
return dto.ScheduleConfig{}, fmt.Errorf("单屏覆盖的实例 id 最长 %d 个字符(当前 %d 个)",
dto.MaxScheduleOverrideKeyChars, utf8.RuneCountInString(key))
}
switch value {
case dto.ScheduleOverrideInherit, dto.ScheduleOverrideOn, dto.ScheduleOverrideOff:
default:
return dto.ScheduleConfig{}, fmt.Errorf(
"单屏覆盖 %s 的取值只能是 inherit / on / off(当前 %q)", key, value)
}
// inherit 是「没有这条设置」(缺省就是它):接受但**不落库**,否则它会白占
// MaxScheduleOverrides 的条数预算 —— 管理端与大屏解析都把这种键当不存在,
// 只有服务端存着的话,攒够 50 条谁都再加不了覆盖。
if value == dto.ScheduleOverrideInherit {
continue
}
overrides[key] = value
}
if len(overrides) == 0 {
overrides = nil
}
}
return dto.ScheduleConfig{
Items: items,
Visible: cfg.Visible,
Side: dto.NormalizeScheduleSide(cfg.Side),
WidthPercent: dto.NormalizeScheduleWidth(cfg.WidthPercent),
ShowNote: showNote,
InstanceOverrides: overrides,
}, nil
}
// displayCommandAudit 是 display.command 的审计载荷:指令内容原样保留(现场按 action 检索的
// 既有查询不受影响),另外记上定向目标与实际命中的实例数。
type displayCommandAudit struct {
@@ -333,6 +458,19 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
if in.Action == "" {
return nil, badRequest("action 必填", nil)
}
// 日程是整份替换,**校验必须排在转发之前**:非法配置一旦先发出去,大屏会先照着一份越界的
// 日程画上一帧,然后才被服务端否定。校验通过的对象在这里就定下来,下面只管转发与落库。
var schedule dto.ScheduleConfig
if in.Action == realtime.CmdSchedule {
cfg, err := scheduleFromPayload(in.Payload)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
schedule = cfg
// 转发的是**归一后**的配置:客户端把 widthPercent 填成 100 时落库是 40,
// 若把原值直接发给大屏,那块屏会先按 100% 画一帧、再被快照纠回来。
in.Payload = cfg
}
matched := 0
// 没接实时层(s.display == nil)时不转发,但下面的公告落库照旧:公告能被后连上来的
// 大屏读到靠的是 settings + change_log,与这一次广播无关。
@@ -368,6 +506,12 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
if err := s.clearAnnouncement(r.Context(), actorID); err != nil {
return nil, err
}
// 日程与公告同一个形状:上面已经把指令转发出去了,这里再把持久值写掉 ——
// 只转发不落库的话,大屏刷新 / 重启(或后连上来)就再也看不到当前日程了。
case realtime.CmdSchedule:
if err := s.saveSchedule(r.Context(), schedule, actorID); err != nil {
return nil, err
}
}
// 审计沿用同一条 action:现场按 action=display.command 检索的既有查询不受影响,
// after 里带了这次的具体 action、公告正文、定向目标与命中数。