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
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -373,6 +374,7 @@ func (s *server) writeSessionHours(ctx context.Context, hours int) error {
|
||||
|
||||
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
|
||||
// 而 PUT /api/settings 是 admin-only,公告的持久化只能走指令这一侧。
|
||||
// 赛事日程侧边栏走的是同一条路(见下面的 saveSchedule)。
|
||||
const (
|
||||
announceTextKey = "display.announce_text"
|
||||
announceSecondsKey = "display.announce_seconds"
|
||||
@@ -384,17 +386,19 @@ func (s *server) saveAnnouncement(ctx context.Context, text string, seconds int,
|
||||
if seconds <= 0 {
|
||||
seconds = defaultAnnounceSeconds
|
||||
}
|
||||
return s.writeAnnouncement(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds))
|
||||
return s.writeSettingValues(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds))
|
||||
}
|
||||
|
||||
// clearAnnouncement 清空当前公告;全屏时长保留,供下次推送沿用。
|
||||
func (s *server) clearAnnouncement(ctx context.Context, actorID int64) error {
|
||||
return s.writeAnnouncement(ctx, actorID, announceTextKey, "")
|
||||
return s.writeSettingValues(ctx, actorID, announceTextKey, "")
|
||||
}
|
||||
|
||||
// writeAnnouncement 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。
|
||||
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到公告变化。
|
||||
func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...string) error {
|
||||
// writeSettingValues 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。
|
||||
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到变化。
|
||||
//
|
||||
// 名字里不绑具体的键:公告与赛事日程都从这条路落库,事务与 change_log 的写法只此一份。
|
||||
func (s *server) writeSettingValues(ctx context.Context, actorID int64, kv ...string) error {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
@@ -422,6 +426,24 @@ func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...str
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 赛事日程侧边栏 ----------
|
||||
|
||||
// saveSchedule 把整份日程配置落库到 display.schedule(读侧见 dto.ParseScheduleValue,
|
||||
// 线上形状见 dto.ScheduleConfig)。
|
||||
//
|
||||
// 整份替换:服务端不做局部合并 —— 客户端手上就有整份配置,合并只会让两边不一致。
|
||||
// 校验与清洗在 scheduleFromPayload 里做完,这里只管序列化与落库。
|
||||
//
|
||||
// ⚠️ 每次保存都会往 change_log 落**整份设置快照**、并让全场大屏做一次全量 resync,
|
||||
// 所以这是**低频**动作(开赛前配一次、现场偶尔勾一下「已完成」),别拿它做高频轮询。
|
||||
func (s *server) saveSchedule(ctx context.Context, cfg dto.ScheduleConfig, actorID int64) error {
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return s.writeSettingValues(ctx, actorID, dto.SettingKeySchedule, string(raw))
|
||||
}
|
||||
|
||||
// ---------- standings ----------
|
||||
|
||||
func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) {
|
||||
|
||||
Reference in new issue
Block a user