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
@@ -986,6 +986,87 @@ func TestSendCommandTargetsRejectsUnknownAction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 赛事日程(schedule)是与公告同路的整份配置:action 在协议清单里、payload 原样落到线上。
|
||||
// 只把 action 加进白名单而漏了 payload 的形状,大屏就会收到一个空的侧边栏。
|
||||
func TestSendCommandScheduleCarriesWholeConfig(t *testing.T) {
|
||||
if !ValidDisplayAction(CmdSchedule) {
|
||||
t.Fatalf("%s 应在协议清单里", CmdSchedule)
|
||||
}
|
||||
env := newTestEnv(t)
|
||||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"})
|
||||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"})
|
||||
a.recvEvent(EventSnapshot)
|
||||
b.recvEvent(EventSnapshot)
|
||||
|
||||
payload := map[string]any{
|
||||
"items": []map[string]any{{"time": "08:30", "title": "开幕式", "done": true}},
|
||||
"visible": true,
|
||||
"side": "left",
|
||||
"widthPercent": float64(22),
|
||||
"showNote": true,
|
||||
"instanceOverrides": map[string]any{"screen-b": "off"},
|
||||
}
|
||||
cmd, err := NewDisplayCommand(CmdSchedule, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 schedule: %v", err)
|
||||
}
|
||||
matched, err := env.hub.SendCommandToInstances(nil, cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("广播日程: %v", err)
|
||||
}
|
||||
if matched != 2 {
|
||||
t.Fatalf("广播应命中 2 个在线实例,得到 %d", matched)
|
||||
}
|
||||
for _, c := range []*wsClient{a, b} {
|
||||
got := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand))
|
||||
if got.Action != CmdSchedule {
|
||||
t.Fatalf("实例应收到 schedule,得到 %s", got.Action)
|
||||
}
|
||||
// 大屏只认 payload 里的键:整份配置(含单屏覆盖)必须逐字透传,服务端不做局部合并。
|
||||
var round map[string]any
|
||||
if err := json.Unmarshal(got.Payload, &round); err != nil {
|
||||
t.Fatalf("解析 payload: %v (%s)", err, got.Payload)
|
||||
}
|
||||
if round["side"] != "left" || round["visible"] != true || round["widthPercent"] != float64(22) {
|
||||
t.Fatalf("配置应原样透传,得到 %+v", round)
|
||||
}
|
||||
items := round["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["title"] != "开幕式" {
|
||||
t.Fatalf("条目应原样透传,得到 %+v", items)
|
||||
}
|
||||
if overrides := round["instanceOverrides"].(map[string]any); overrides["screen-b"] != "off" {
|
||||
t.Fatalf("单屏覆盖应原样透传,得到 %+v", overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// schedule 也要走定向通道(与公告同一套 targets 语义)。
|
||||
func TestSendCommandScheduleTargetsSingleInstance(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"})
|
||||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"})
|
||||
a.recvEvent(EventSnapshot)
|
||||
b.recvEvent(EventSnapshot)
|
||||
|
||||
cmd, err := NewDisplayCommand(CmdSchedule, map[string]any{"visible": false, "items": []any{}})
|
||||
if err != nil {
|
||||
t.Fatalf("构造 schedule: %v", err)
|
||||
}
|
||||
matched, err := env.hub.SendCommandToInstances([]string{"screen-a"}, cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("定向日程: %v", err)
|
||||
}
|
||||
if matched != 1 {
|
||||
t.Fatalf("命中实例数应为 1,得到 %d", matched)
|
||||
}
|
||||
if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdSchedule {
|
||||
t.Fatalf("被点名的实例应收到 schedule,得到 %s", got.Action)
|
||||
}
|
||||
if ev, _, ok := b.recvWithin(200 * time.Millisecond); ok {
|
||||
t.Fatalf("未被点名的实例不该收到消息,得到 %s", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// 屏号上报:Console 窗口带 screen,局域网浏览器不带(空串),两者都要能查出来。
|
||||
func TestDisplayInstancesReportScreen(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
@@ -64,6 +64,9 @@ const (
|
||||
CmdHideDetail = "hide-detail"
|
||||
// CmdAnnounceClear 清除当前公告:服务端同时清设置里的持久值并广播给大屏。
|
||||
CmdAnnounceClear = "announce_clear"
|
||||
// CmdSchedule 整份替换「赛事日程侧边栏」配置(payload 形状见 dto.ScheduleConfig):
|
||||
// 服务端校验后一次请求里既转发给大屏、也落库到 settings,刷新 / 重启不丢(与公告同路)。
|
||||
CmdSchedule = "schedule"
|
||||
)
|
||||
|
||||
var displayActions = map[string]bool{
|
||||
@@ -75,6 +78,7 @@ var displayActions = map[string]bool{
|
||||
CmdAnnounceClear: true,
|
||||
CmdShowDetail: true,
|
||||
CmdHideDetail: true,
|
||||
CmdSchedule: true,
|
||||
}
|
||||
|
||||
// ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。
|
||||
|
||||
@@ -420,6 +420,42 @@ func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 赛事日程侧边栏随 settings 下发(不用改快照:display.* 不是 admin-only 字段)。
|
||||
// 库里那个值被改坏时快照必须照常构建、schedule 当空 —— 一条脏设置不能让大屏连不上。
|
||||
func TestSnapshotScheduleToleratesBrokenValue(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
mustExec(t, db, `INSERT INTO settings(key, value, updated_at) VALUES('display.schedule', ?, datetime('now'))`,
|
||||
`{"items":[{"time":"08:30","title":"开幕式","done":true}],`+
|
||||
`"visible":true,"side":"left","widthPercent":22,"showNote":true}`)
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
got := snap.Settings.Display.Schedule
|
||||
if got == nil || !got.Visible || got.Side != dto.ScheduleSideLeft || len(got.Items) != 1 {
|
||||
t.Fatalf("日程应随快照下发: %+v", got)
|
||||
}
|
||||
// 线上 JSON 用大屏解析的键名(前端与 REST 同一份形状)。
|
||||
raw, err := json.Marshal(snap.Settings)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化 settings: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"schedule":{"items":[{"time":"08:30","title":"开幕式","done":true}]`) {
|
||||
t.Fatalf("快照里日程的形状不对: %s", raw)
|
||||
}
|
||||
|
||||
mustExec(t, db, `UPDATE settings SET value = '{坏 JSON' WHERE key = 'display.schedule'`)
|
||||
snap, err = env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("坏数据下快照仍应构建成功: %v", err)
|
||||
}
|
||||
if snap.Settings.Display.Schedule != nil {
|
||||
t.Fatalf("坏数据应读成 null,得到 %+v", snap.Settings.Display.Schedule)
|
||||
}
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(q, args...); err != nil {
|
||||
|
||||
Reference in new issue
Block a user