Files
AskaEth 9c52dc4b38 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。
2026-09-17 20:04:50 +08:00

250 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) }