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
@@ -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、公告正文、定向目标与命中数。
|
||||
|
||||
Reference in new issue
Block a user