## 大屏控制(用户:管理面板只能看到本机显示器,看不到局域网设备;也不能给某块屏单独切页) - 服务端:`POST /api/display/command` 新增可选 `targets []string` —— 空 = 广播(公告语义不变), 非空 = 只发给点名的实例并返回命中数;命中 0 个返回 404 +「目标大屏已离线,请刷新在线列表」 (不落审计、公告也不落库)。`DisplaySender` 签名随之扩展,新增 `Hub.SendCommandToInstances`(与按房间的 SendCommand 并存)。 - 服务端:WS `hello` 新增可选 `screen`,`realtime.Instance` 新增 `Screen`(不带 omitempty, 局域网浏览器恒为空串),这样管理端能把「本机显示器」与「在线实例」精确对上。 - 管理端「大屏控制」:写死的「在线看板」占位换成真实列表(5 秒轮询、页面隐藏停表), 列出实例 id / 在线离线 / 落后 / 屏号或「局域网浏览器」/ 当前页 / 来源 / 心跳; 每行可单独切页;本机显示器卡片补「当前页」与逐屏切页;离线时控件置灰并说明原因。 页面下拉改为共用大屏那份页名清单(管理端两处标签随之变为「个人积分奖牌榜」「分项成绩」)。 ## 导出(用户:保留多页工作簿的同时,再要一种全平铺单页;积分榜明细也要能导出) - 新增 `GET /api/export/all-flat.xlsx` → `赛事成绩总表(单页).xlsx`:一个工作簿一张表, 列 = 大项/小项/组别/名次/号码/单位/运动员-队/成绩/积分/状态/备注;前三列取 `EventSheet` 的结构化字段(不解析标题),后 8 列就是 `eventColumns` 本身(append 拼出, 两处不可能漂移)。**既有「全部」(all.xlsx,多 sheet)一字未动。** - 新增 `GET /api/export/teams.xlsx` → `单位积分总明细.xlsx`:一张带「单位」列的总表 + 每个有明细行的单位一张分表;**无明细行的单位不出分表**(空表没信息量,且会把假删除的 隐藏单位名字重新印进导出件);已隐藏但有流水的单位照常出表(它在榜单上)。 - 积分榜页:页头「导出总明细」、单位明细面板「导出该单位明细」;导出页新增「全部(单页)」。 测试:服务端 12 包全绿 / 347 个测试函数;前端 vue-tsc 通过、184 例全过;Console 18 文件。 docs/ 与 devdocs/ 同步(含新增 Q65–Q70 与两轮进度记录)。
179 lines
5.3 KiB
Go
179 lines
5.3 KiB
Go
// Package realtime 实现 /ws 实时同步中心。
|
|
//
|
|
// 协议以 devdocs/05-接口与实时协议.md 第 1–5 节为唯一规范:
|
|
// 版本号取自 change_log.id,客户端按 version 去重,增量补不上就回落全量快照。
|
|
package realtime
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// 事件名与协议第 3 节的表格一一对应。
|
|
const (
|
|
EventHello = "hello"
|
|
EventSnapshot = "snapshot"
|
|
EventCatchup = "catchup"
|
|
EventChange = "change"
|
|
EventResync = "resync"
|
|
EventAck = "ack"
|
|
EventHeartbeat = "heartbeat"
|
|
EventDisplayCommand = "display:command"
|
|
)
|
|
|
|
// 房间名。admin:<userId> 由 AdminRoom 生成,用于定向通知。
|
|
const (
|
|
RoomDisplay = "display"
|
|
RoomAdmin = "admin"
|
|
)
|
|
|
|
// AdminRoom 返回某个管理端用户的定向房间名。
|
|
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"
|
|
)
|
|
|
|
var displayActions = map[string]bool{
|
|
CmdGoto: true,
|
|
CmdLock: true,
|
|
CmdUnlock: true,
|
|
CmdReload: true,
|
|
CmdAnnounce: true,
|
|
CmdAnnounceClear: true,
|
|
CmdShowDetail: true,
|
|
CmdHideDetail: 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"`
|
|
}
|
|
|
|
// 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) }
|