Files
Scoreboard/server/internal/realtime/instances.go
T
AskaEth 782a38f82f feat: 大屏控制可看局域网设备并逐台切页 + 两种新导出(单页成绩 / 单位积分总明细)
## 大屏控制(用户:管理面板只能看到本机显示器,看不到局域网设备;也不能给某块屏单独切页)
- 服务端:`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 与两轮进度记录)。
2026-09-11 00:44:03 +08:00

218 lines
5.7 KiB
Go
Raw 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
import (
"crypto/rand"
"encoding/hex"
"sort"
"strconv"
"time"
)
// 注册表容量控制:离线实例保留一段时间便于排查,过期即清理;
// 没有 displayKey 的客户端每次重连都会生成新实例,必须有上限兜底。
const (
instanceTTL = 10 * time.Minute
maxInstances = 200
)
// Instance 是大屏实例的对外快照,供管理端查询「谁在线、在哪一页」。
type Instance struct {
ID string `json:"id"`
Online bool `json:"online"`
Page string `json:"page,omitempty"`
LastHeartbeat time.Time `json:"lastHeartbeat"`
ConnectedAt time.Time `json:"connectedAt"`
RemoteAddr string `json:"remoteAddr,omitempty"`
AckVersion int64 `json:"ackVersion"`
Slow bool `json:"slow"`
// Screen 是 Console 分配的屏号(URL 里的 screen=<display.id>),
// 管理端靠它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时为空串。
// 用字符串而不是数字:显示器的 id 由 Electron 给,不保证是小的正整数。
Screen string `json:"screen"`
}
// instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。
type instanceState struct {
id string
page string
screen string
connectedAt time.Time
lastHeartbeat time.Time
remoteAddr string
ackVersion int64
slow bool
conns int
}
// registerDisplay 把连接挂到大屏实例上;未带 instanceId 时由服务端生成,
// 保证「刷新页面 = 同一实例」之外的连接也能被登记和查询。
func (h *Hub) registerDisplay(c *client, id, page, screen string) string {
if id == "" {
id = "display-" + randomID()
}
now := time.Now()
c.mu.Lock()
prev := c.instanceID
c.instanceID = id
c.mu.Unlock()
// 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。
if prev != "" && prev != id {
h.releaseDisplayID(prev, c)
}
h.mu.Lock()
defer h.mu.Unlock()
h.pruneInstancesLocked(now)
st := h.instances[id]
if st == nil {
st = &instanceState{id: id, connectedAt: now}
h.instances[id] = st
}
if prev != id {
st.conns++
}
if page != "" {
st.page = page
}
if screen != "" {
st.screen = screen
}
st.lastHeartbeat = now
st.remoteAddr = c.remoteAddr
return id
}
// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查;
// 离线超过 instanceTTL 后由 pruneInstancesLocked 清理。
func (h *Hub) releaseDisplay(c *client) {
c.mu.Lock()
id := c.instanceID
c.mu.Unlock()
if id == "" {
return
}
h.releaseDisplayID(id, c)
}
func (h *Hub) releaseDisplayID(id string, c *client) {
h.mu.Lock()
defer h.mu.Unlock()
st := h.instances[id]
if st == nil {
return
}
if st.conns > 0 {
st.conns--
}
st.lastHeartbeat = time.Now()
st.ackVersion = c.ackVersion.Load()
st.slow = c.slow.Load()
}
// touchInstance 在收到客户端消息或下发心跳时刷新「最后心跳」。
func (h *Hub) touchInstance(c *client, now time.Time) {
c.mu.Lock()
id := c.instanceID
c.mu.Unlock()
if id == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if st := h.instances[id]; st != nil {
st.lastHeartbeat = now
st.ackVersion = c.ackVersion.Load()
st.slow = c.slow.Load()
}
}
// setInstancePage 记录服务端刚下发的目标页,让注册表里的「当前页」跟得上 goto 指令。
// targets 为空表示广播(所有在线实例),否则只更新被点名的那几个实例——定向切页后
// 每块屏的「当前页」必须各算各的。
func (h *Hub) setInstancePage(page string, targets []string) {
if page == "" {
return
}
var picked map[string]bool
if len(targets) > 0 {
picked = make(map[string]bool, len(targets))
for _, id := range targets {
picked[id] = true
}
}
h.mu.Lock()
defer h.mu.Unlock()
for _, st := range h.instances {
if st.conns == 0 {
continue
}
if picked != nil && !picked[st.id] {
continue
}
st.page = page
}
}
// pruneInstancesLocked 清理过期离线实例并把注册表封顶到 maxInstances;
// 调用方必须持有 h.mu。淘汰只针对离线实例,保证在线屏幕不会被误删。
func (h *Hub) pruneInstancesLocked(now time.Time) {
for id, st := range h.instances {
if st.conns == 0 && now.Sub(st.lastHeartbeat) > instanceTTL {
delete(h.instances, id)
}
}
if len(h.instances) <= maxInstances {
return
}
offline := make([]*instanceState, 0, len(h.instances))
for _, st := range h.instances {
if st.conns == 0 {
offline = append(offline, st)
}
}
sort.Slice(offline, func(i, j int) bool { return offline[i].lastHeartbeat.Before(offline[j].lastHeartbeat) })
for _, st := range offline {
if len(h.instances) <= maxInstances {
break
}
delete(h.instances, st.id)
}
}
// Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。
func (h *Hub) Instances() []Instance {
h.mu.Lock()
defer h.mu.Unlock()
h.pruneInstancesLocked(time.Now())
out := make([]Instance, 0, len(h.instances))
for _, st := range h.instances {
out = append(out, Instance{
ID: st.id,
Online: st.conns > 0,
Page: st.page,
LastHeartbeat: st.lastHeartbeat,
ConnectedAt: st.connectedAt,
RemoteAddr: st.remoteAddr,
AckVersion: st.ackVersion,
Slow: st.slow,
Screen: st.screen,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func randomID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// 随机源不可用时退回时间戳,实例 id 只需唯一,不承担安全职责。
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b[:])
}