主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。
设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 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。
250 lines
8.4 KiB
Go
250 lines
8.4 KiB
Go
// Package realtime 实现 /ws 实时同步中心。
|
||
//
|
||
// 协议以 devdocs/05-接口与实时协议.md 第 1–5 节为唯一规范:
|
||
// 版本号取自 change_log.id,客户端按 version 去重,增量补不上就回落全量快照。
|
||
package realtime
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
)
|
||
|
||
// 事件名与协议第 3 节的表格一一对应。
|
||
const (
|
||
EventHello = "hello"
|
||
EventSnapshot = "snapshot"
|
||
EventCatchup = "catchup"
|
||
EventChange = "change"
|
||
EventResync = "resync"
|
||
EventAck = "ack"
|
||
EventHeartbeat = "heartbeat"
|
||
EventDisplayCommand = "display:command"
|
||
)
|
||
|
||
// 房间名。admin:<userId> 由 AdminRoom 生成,用于定向通知。
|
||
//
|
||
// admin:<userId> 目前没有生产推送方(只有测试用 BroadcastTo 往它发过消息):
|
||
// 保留它是因为 devdocs/05 §1 把「admin:<userId>(定向通知)」写成了协议的一部分,
|
||
// 要删就得连着文档一起改,而文档不在本轮可改范围内——见审查清单 C36。
|
||
const (
|
||
RoomDisplay = "display"
|
||
RoomAdmin = "admin"
|
||
)
|
||
|
||
// AdminRoom 返回某个管理端用户的定向房间名。
|
||
//
|
||
// 只有带会话的管理端连接会加入这个房间(见 handleHello),身份来自会话而非客户端自报;
|
||
// 「guest 不能进管理端房间」这条鉴权语义由测试钉住,删房间会一起删掉那条覆盖。
|
||
func AdminRoom(userID int64) string { return fmt.Sprintf("admin:%d", userID) }
|
||
|
||
// 连接角色。
|
||
const (
|
||
RoleDisplay = "display"
|
||
RoleAdmin = "admin"
|
||
)
|
||
|
||
// change.action 取值。
|
||
const (
|
||
ActionUpsert = "upsert"
|
||
ActionVoid = "void"
|
||
ActionReplace = "replace"
|
||
)
|
||
|
||
// display:command 支持的 action。
|
||
const (
|
||
CmdGoto = "goto"
|
||
CmdLock = "lock"
|
||
CmdUnlock = "unlock"
|
||
CmdReload = "reload"
|
||
CmdAnnounce = "announce"
|
||
CmdShowDetail = "show-detail"
|
||
CmdHideDetail = "hide-detail"
|
||
// CmdAnnounceClear 清除当前公告:服务端同时清设置里的持久值并广播给大屏。
|
||
CmdAnnounceClear = "announce_clear"
|
||
// CmdSchedule 整份替换「赛事日程侧边栏」配置(payload 形状见 dto.ScheduleConfig):
|
||
// 服务端校验后一次请求里既转发给大屏、也落库到 settings,刷新 / 重启不丢(与公告同路)。
|
||
CmdSchedule = "schedule"
|
||
)
|
||
|
||
var displayActions = map[string]bool{
|
||
CmdGoto: true,
|
||
CmdLock: true,
|
||
CmdUnlock: true,
|
||
CmdReload: true,
|
||
CmdAnnounce: true,
|
||
CmdAnnounceClear: true,
|
||
CmdShowDetail: true,
|
||
CmdHideDetail: true,
|
||
CmdSchedule: true,
|
||
}
|
||
|
||
// ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。
|
||
func ValidDisplayAction(action string) bool { return displayActions[action] }
|
||
|
||
// envelope 是所有消息的统一外壳:{ "event": "...", "data": { ... } }。
|
||
type envelope struct {
|
||
Event string `json:"event"`
|
||
Data json.RawMessage `json:"data,omitempty"`
|
||
}
|
||
|
||
// encode 把事件名与载荷打包成一条文本帧。
|
||
func encode(event string, data any) ([]byte, error) {
|
||
raw, err := json.Marshal(data)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("编码 %s 载荷: %w", event, err)
|
||
}
|
||
return json.Marshal(envelope{Event: event, Data: raw})
|
||
}
|
||
|
||
// helloPayload 是 C→S 的 hello 载荷。
|
||
//
|
||
// role 只是「申请」:服务端以会话身份为准,未登录连接申请 admin 会被拒绝,
|
||
// 因此这里没有 userId —— 定向房间的 userId 只能来自会话。
|
||
// instanceId / page / screen 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页、哪块屏」,
|
||
// 老客户端不带这几个字段也能正常工作。
|
||
type helloPayload struct {
|
||
Role string `json:"role"`
|
||
LastVersion *int64 `json:"lastVersion,omitempty"`
|
||
DisplayKey string `json:"displayKey,omitempty"`
|
||
InstanceID string `json:"instanceId,omitempty"`
|
||
Page string `json:"page,omitempty"`
|
||
// Screen 是 Console 从 URL 带下来的屏号(screen=<display.id>),局域网浏览器留空。
|
||
Screen string `json:"screen,omitempty"`
|
||
// Device 是浏览器自己拼好的设备标签(`安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`),
|
||
// DeviceRaw 是原始 UA(排障用)。两者都 omitempty:**老客户端不带这两个字段,服务端行为
|
||
// 与今天逐字一致**(管理端列表退回显示实例 id)。
|
||
//
|
||
// 服务端不解析 UA,但也不能全信这两个值:它们是客户端自报的自由文本,会直接进管理端列表。
|
||
// 长度按**字符**截断、控制字符一律清掉,收口在 sanitizeDeviceText(registerDisplay 调用)。
|
||
Device string `json:"device,omitempty"`
|
||
DeviceRaw string `json:"deviceRaw,omitempty"`
|
||
}
|
||
|
||
// 客户端自报文本的长度上限,按**字符**(rune)不是字节算:
|
||
// `安卓 13 · Chrome 120` 这种标签按字节截会切出半个汉字,显示成乱码方框。
|
||
const (
|
||
maxDeviceChars = 80
|
||
maxDeviceRawChars = 300
|
||
)
|
||
|
||
// CleanUserText 清洗客户端自报的自由文本:去掉全部控制字符(换行、制表、退格、DEL…)与首尾空白。
|
||
//
|
||
// 这些值最终要显示在管理端「在线大屏」列表里,一个带换行的 device 能把整行排版顶歪;
|
||
// 服务端是唯一的收口处,不能指望每个客户端都干净。TrimSpace 之后为空即视作「没报」,返回空串。
|
||
//
|
||
// 保留字符间的普通空格与各种可见符号:设备标签本来就长这样,不该被服务端改写。
|
||
func CleanUserText(s string) string {
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
b.Grow(len(s))
|
||
for _, r := range s {
|
||
if unicode.IsControl(r) {
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return strings.TrimSpace(b.String())
|
||
}
|
||
|
||
// TruncateChars 按字符(rune)截断到 maxChars 个;maxChars <= 0 表示不截断。
|
||
// 会切断代理对时以整字符为界(Go 的 range 只落在字符起始字节上),不会切出半个 UTF-8 序列。
|
||
func TruncateChars(s string, maxChars int) string {
|
||
if maxChars <= 0 {
|
||
return s
|
||
}
|
||
count := 0
|
||
for i := range s {
|
||
if count == maxChars {
|
||
return s[:i]
|
||
}
|
||
count++
|
||
}
|
||
return s
|
||
}
|
||
|
||
// sanitizeDeviceText 是 hello 里设备字段的收口:先清洗再截断。
|
||
// 没有这两个字段的老客户端拿到的是空串,注册表按「没报」处理。
|
||
func sanitizeDeviceText(s string, maxChars int) string {
|
||
return TruncateChars(CleanUserText(s), maxChars)
|
||
}
|
||
|
||
// ackPayload 是 C→S 的 ack 载荷;服务端据此识别慢客户端。
|
||
type ackPayload struct {
|
||
Version int64 `json:"version"`
|
||
}
|
||
|
||
// Change 是变更的最小单元;Version 只用于服务端排序与去重,不上线路。
|
||
type Change struct {
|
||
Entity string `json:"entity"`
|
||
ID int64 `json:"id,omitempty"`
|
||
Action string `json:"action"`
|
||
Data json.RawMessage `json:"data,omitempty"`
|
||
Version int64 `json:"-"`
|
||
}
|
||
|
||
// changePayload 是 change 事件的载荷。
|
||
type changePayload struct {
|
||
Version int64 `json:"version"`
|
||
Changes []Change `json:"changes"`
|
||
}
|
||
|
||
// catchupPayload 是 catchup 事件的载荷。
|
||
type catchupPayload struct {
|
||
FromVersion int64 `json:"fromVersion"`
|
||
Version int64 `json:"version"`
|
||
Changes []Change `json:"changes"`
|
||
}
|
||
|
||
// heartbeatPayload 是 heartbeat 事件的载荷。
|
||
type heartbeatPayload struct {
|
||
Version int64 `json:"version"`
|
||
ServerTime string `json:"serverTime"`
|
||
}
|
||
|
||
// DisplayCommand 是下发给大屏的 display:command 载荷。
|
||
type DisplayCommand struct {
|
||
Action string `json:"action"`
|
||
Payload json.RawMessage `json:"payload,omitempty"`
|
||
}
|
||
|
||
// NewDisplayCommand 构造一条大屏指令;action 不在协议清单内时返回错误。
|
||
func NewDisplayCommand(action string, payload any) (DisplayCommand, error) {
|
||
if !ValidDisplayAction(action) {
|
||
return DisplayCommand{}, fmt.Errorf("未知的大屏指令 %q", action)
|
||
}
|
||
cmd := DisplayCommand{Action: action}
|
||
if payload != nil {
|
||
raw, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return DisplayCommand{}, fmt.Errorf("编码大屏指令 %s 的 payload: %w", action, err)
|
||
}
|
||
cmd.Payload = raw
|
||
}
|
||
return cmd, nil
|
||
}
|
||
|
||
// pageFromPayload 从 goto 指令的 payload 里取目标页名,兼容字符串与对象两种写法。
|
||
func pageFromPayload(payload json.RawMessage) string {
|
||
if len(payload) == 0 {
|
||
return ""
|
||
}
|
||
var s string
|
||
if err := json.Unmarshal(payload, &s); err == nil {
|
||
return s
|
||
}
|
||
var obj struct {
|
||
Page string `json:"page"`
|
||
}
|
||
if err := json.Unmarshal(payload, &obj); err == nil {
|
||
return obj.Page
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func nowISO(t time.Time) string { return t.Format(time.RFC3339Nano) }
|