Files
Scoreboard/server/internal/httpapi/live_events.go
T
AskaEth 08a5740fb2 feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相
## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发)
- 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、
  手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。
- 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错
  (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。

## 实时赛况(新大屏页 + 定向接口)
- 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩
  (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。
  设置键 display.live_events / display.live_source。
- 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面
  (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。
- 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only),
  故对非管理员整组置灰 + 提示条(不新增角色)。

## 显式批次层(组别之下)
- 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果
  完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 +
  一键均分(按 N 人切片,已有批次号接着往后排)。

## 成绩输入框实时悬浮提示
- 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻;
  合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文;
  与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。

## 窄屏 / 手机端布局
- 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针,
  四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框
  塌成 22px 这类真问题。

## 版本号单一真相
- 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json
  的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。

测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
2026-09-11 04:05:02 +08:00

381 lines
12 KiB
Go

package httpapi
import (
"context"
"database/sql"
"errors"
"net/http"
"sort"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
)
// 「实时赛况」大屏页的当前项目判定。
//
// 现场节奏:一个项目跑完一批就发布一批,下一场开始前大屏要一直显示上一场。
// 判定顺序(display.live_source=auto 时):
// 1. 人工指定了 display.live_events → 用人工指定的(最多 2 个);
// 2. 否则取 scheduled_at 落在「现在 ± liveEventWindow」内的小项,按离现在多近排序,最多 2 个;
// 3. 都没有 → 取最近一次发布过成绩的小项(用户要求的「下一场开始前持续显示上一场」)。
//
// manual 时只认人工指定,不做任何回退。
const (
// liveEventWindow 是排期窗口的半径:项目开始时间前后各 30 分钟都算「正在进行」。
// 取 30 分钟是因为一个田径小项(含多轮次)通常不会超过一小时;
// 想更精确可以后续做成设置项,这里先给一个现场够用的固定值。
liveEventWindow = 30 * time.Minute
liveReasonManual = "manual"
liveReasonScheduled = "scheduled"
liveReasonRecent = "recent"
)
// liveScheduledCandidate 是排期命中的候选小项。
type liveScheduledCandidate struct {
ID int64
At time.Time
SortOrder int
}
// liveSelection 是判定结果:id 列表与每个 id 的入选原因。
type liveSelection struct {
Source string
IDs []int64
Reasons map[int64]string
}
// pickLiveEvents 是纯函数形式的回退链,方便单测覆盖各种组合。
// scheduled 传入的候选必须已经按窗口筛过。
func pickLiveEvents(source string, manual []int64, scheduled []liveScheduledCandidate, recent int64, now time.Time) liveSelection {
out := liveSelection{Source: source, IDs: make([]int64, 0, dto.MaxLiveEvents), Reasons: map[int64]string{}}
take := func(ids []int64, reason string) {
for _, id := range ids {
if id <= 0 || len(out.IDs) >= dto.MaxLiveEvents {
return
}
if _, ok := out.Reasons[id]; ok {
continue
}
out.IDs = append(out.IDs, id)
out.Reasons[id] = reason
}
}
if source == dto.LiveSourceManual {
take(manual, liveReasonManual)
return out
}
if len(manual) > 0 {
take(manual, liveReasonManual)
return out
}
if len(scheduled) > 0 {
sort.SliceStable(scheduled, func(i, j int) bool {
di := absDuration(scheduled[i].At.Sub(now))
dj := absDuration(scheduled[j].At.Sub(now))
if di != dj {
return di < dj
}
if scheduled[i].SortOrder != scheduled[j].SortOrder {
return scheduled[i].SortOrder < scheduled[j].SortOrder
}
return scheduled[i].ID < scheduled[j].ID
})
ids := make([]int64, 0, len(scheduled))
for _, c := range scheduled {
ids = append(ids, c.ID)
}
take(ids, liveReasonScheduled)
return out
}
if recent > 0 {
take([]int64{recent}, liveReasonRecent)
}
return out
}
func absDuration(d time.Duration) time.Duration {
if d < 0 {
return -d
}
return d
}
// resolveLiveEvents 读取设置与赛事库,判定当前项目。
func (s *server) resolveLiveEvents(ctx context.Context, now time.Time) (liveSelection, error) {
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return liveSelection{}, err
}
source := dto.NormalizeLiveSource(values[dto.SettingKeyLiveSource])
manual, err := s.existingEventIDs(ctx, dto.ParseLiveEventIDs(values[dto.SettingKeyLiveEvents]))
if err != nil {
return liveSelection{}, err
}
// manual 模式不必查排期与最近发布,省掉两次查询。
if source == dto.LiveSourceManual {
return pickLiveEvents(source, manual, nil, 0, now), nil
}
scheduled, err := s.scheduledLiveCandidates(ctx, now)
if err != nil {
return liveSelection{}, err
}
recent, err := s.mostRecentPublishedEvent(ctx)
if err != nil {
return liveSelection{}, err
}
return pickLiveEvents(source, manual, scheduled, recent, now), nil
}
// existingEventIDs 丢掉已不存在的小项 id:人工指定的小项被删除后,
// 大屏不该因为一个残留 id 报错或整块空白。
func (s *server) existingEventIDs(ctx context.Context, ids []int64) ([]int64, error) {
out := make([]int64, 0, len(ids))
for _, id := range ids {
var one int
err := s.ev.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
continue
case err != nil:
return nil, internal(err)
default:
out = append(out, id)
}
}
return out, nil
}
// scheduledLiveCandidates 取 scheduled_at 落在当前窗口内的小项。
func (s *server) scheduledLiveCandidates(ctx context.Context, now time.Time) ([]liveScheduledCandidate, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, scheduled_at, sort_order FROM events
WHERE scheduled_at IS NOT NULL AND TRIM(scheduled_at) <> ''`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]liveScheduledCandidate, 0)
for rows.Next() {
var (
c liveScheduledCandidate
raw string
sortOrd int64
)
if err := rows.Scan(&c.ID, &raw, &sortOrd); err != nil {
return nil, internal(err)
}
at, ok := parseScheduledAt(raw)
if !ok || absDuration(at.Sub(now)) > liveEventWindow {
continue
}
c.At = at
c.SortOrder = int(sortOrd)
out = append(out, c)
}
return out, rows.Err()
}
// mostRecentPublishedEvent 取最近一次发布过成绩的小项,取不到时返回 0(全新赛事)。
func (s *server) mostRecentPublishedEvent(ctx context.Context) (int64, error) {
var id int64
err := s.ev.QueryRowContext(ctx, `
SELECT r.event_id
FROM results r
JOIN events ev ON ev.id = r.event_id
WHERE r.state = 'published' AND r.status <> 'withdrawn'
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC
LIMIT 1`).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, internal(err)
}
return id, nil
}
// parseScheduledAt 解析 events.scheduled_at。
//
// 管理端存的是 datetime-local 转出的 RFC3339(UTC),但手工改库或早期数据可能是
// 其它几种常见写法;认不出来的一律当成「没有排期」,不让一个大屏因为格式怪就整块不显示。
func parseScheduledAt(raw string) (time.Time, bool) {
v := strings.TrimSpace(raw)
if v == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02",
}
for _, layout := range layouts {
if at, err := time.Parse(layout, v); err == nil {
return at, true
}
}
return time.Time{}, false
}
// dedupeIDs 去重并丢掉非法 id(0 / 负数),保持原顺序。
func dedupeIDs(ids []int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]bool, len(ids))
for _, id := range ids {
if id <= 0 || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
// ---------- 接口 ----------
// liveEventsHandler 是 GET /api/display/live:大屏「实时赛况」页拉当前项目与已发布成绩。
// 同样对 guest 开放——局域网大屏不带会话,判定结论本身不含任何管理配置。
func (s *server) liveEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
ctx := r.Context()
sel, err := s.resolveLiveEvents(ctx, time.Now())
if err != nil {
return nil, err
}
out := dto.LiveEvents{Source: sel.Source, EventIDs: sel.IDs, Events: make([]dto.LiveEvent, 0, len(sel.IDs))}
for _, id := range sel.IDs {
ev, err := loadEventQ(ctx, s.ev, id)
if err != nil {
return nil, err
}
groups, err := s.liveResultGroups(ctx, id)
if err != nil {
return nil, err
}
out.Events = append(out.Events, dto.LiveEvent{
Event: dto.EventFromDomain(ev),
Reason: sel.Reasons[id],
Groups: groups,
})
}
return out, nil
}
// liveResultGroups 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。
//
// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序),
// 但范围固定在这一个小项、且不设条数上限——当前项目要显示完整名单,不能被 20 条截断。
func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.EventResultGroup, error) {
rows, err := s.ev.QueryContext(ctx, `
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''),
e.category_id, COALESCE(cat.name, ''),
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''),
r.entry_id, r.rank, e.team_id,
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path,
e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''),
COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status
FROM results r
JOIN entries e ON e.id = r.entry_id
JOIN events ev ON ev.id = r.event_id
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN teams t ON t.id = e.team_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE r.event_id = ? AND r.state = 'published' AND r.status <> 'withdrawn'
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
index := make(map[int64]int)
out := make([]dto.EventResultGroup, 0)
for rows.Next() {
var (
eventCode, eventName string
categoryID int64
categoryName string
publishedAt string
row dto.EventResultRow
rank sql.NullInt64
value sql.NullFloat64
athleteID sql.NullInt64
athleteName string
shortName string
logoPath sql.NullString
memberNames sql.NullString
isTie int
)
if err := rows.Scan(&eventCode, &eventName, &categoryID, &categoryName, &publishedAt,
&row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &logoPath,
&athleteID, &athleteName, &memberNames,
&row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil {
return nil, internal(err)
}
if rank.Valid {
v := int(rank.Int64)
row.Rank = &v
}
if value.Valid {
v := value.Float64
row.ResultValue = &v
}
if athleteID.Valid && athleteID.Int64 != 0 {
v := athleteID.Int64
row.AthleteID = &v
}
row.AthleteName = nilIfEmpty(athleteName)
row.ShortName = nilIfEmpty(shortName)
row.LogoPath = nilIfEmpty(logoPath.String)
row.MemberNames = parseNames(memberNames)
row.IsTie = isTie != 0
key := categoryID
i, ok := index[key]
if !ok {
i = len(out)
index[key] = i
out = append(out, dto.EventResultGroup{
EventID: eventID,
EventCode: eventCode,
EventName: eventName,
CategoryID: categoryID,
CategoryName: categoryName,
PublishedAt: nilIfEmpty(publishedAt),
Rows: []dto.EventResultRow{},
})
}
out[i].Rows = append(out[i].Rows, row)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
// 行是按发布时间倒序取回的,组内要改回名次顺序;无名次的排最后。
for i := range out {
sort.SliceStable(out[i].Rows, func(a, b int) bool {
ra, rb := out[i].Rows[a], out[i].Rows[b]
switch {
case ra.Rank == nil && rb.Rank == nil:
return ra.EntryID < rb.EntryID
case ra.Rank == nil:
return false
case rb.Rank == nil:
return true
default:
return *ra.Rank < *rb.Rank
}
})
}
return out, nil
}