## 分批发布(现场: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 自定义大屏模板设计文档)。
245 lines
9.1 KiB
Go
245 lines
9.1 KiB
Go
// Package dto 存放实时层与 REST 层共用的线上数据结构与纯转换函数。
|
||
//
|
||
// 放在这里而不是 httpapi:realtime 与 httpapi 是同层出口,互相 import 会造成分层倒置;
|
||
// 本包只依赖 domain 与标准库,不做任何 IO。
|
||
package dto
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构,
|
||
// 与 web/src/shared/types.ts 的 EventSettings 一一对应。
|
||
type EventSettings struct {
|
||
Event EventSettingsEvent `json:"event"`
|
||
Display EventSettingsDisplay `json:"display"`
|
||
Standings EventSettingsStandings `json:"standings"`
|
||
DefaultProfileID *int64 `json:"defaultProfileId"`
|
||
RequireApproval bool `json:"requireApproval"`
|
||
}
|
||
|
||
// EventSettingsEvent 是赛事基本信息。
|
||
type EventSettingsEvent struct {
|
||
Name string `json:"name"`
|
||
Subtitle *string `json:"subtitle"`
|
||
Host *string `json:"host"`
|
||
LogoPath *string `json:"logoPath"`
|
||
PrimaryColor *string `json:"primaryColor"`
|
||
}
|
||
|
||
// EventSettingsDisplay 是大屏展示配置。
|
||
type EventSettingsDisplay struct {
|
||
Background *string `json:"background"`
|
||
Logo *string `json:"logo"`
|
||
Title string `json:"title"`
|
||
Subtitle *string `json:"subtitle"`
|
||
PrimaryColor *string `json:"primaryColor"`
|
||
RotateSecond int `json:"rotateSeconds"`
|
||
// Announce 是当前公告正文,由大屏指令写入;null 表示无公告。
|
||
Announce *string `json:"announce"`
|
||
// FontTitle / FontBody 是大屏标题与正文用的字体 id(builtin:* 或 custom:<文件名>);
|
||
// null 表示用内置默认字体,取值合法性由 httpapi 在写入时校验。
|
||
FontTitle *string `json:"fontTitle"`
|
||
FontBody *string `json:"fontBody"`
|
||
// LiveEvents 是「实时赛况」页人工指定的当前项目 id,最多 MaxLiveEvents 个。
|
||
// 库里以逗号分隔的 id 存 display.live_events;空数组表示没有人工指定。
|
||
LiveEvents []int64 `json:"liveEvents"`
|
||
// LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走
|
||
// 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
|
||
LiveSource string `json:"liveSource"`
|
||
}
|
||
|
||
// 「大屏实时赛况」的两个设置键与取值约束。
|
||
const (
|
||
// SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。
|
||
SettingKeyLiveEvents = "display.live_events"
|
||
SettingKeyLiveSource = "display.live_source"
|
||
// MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。
|
||
MaxLiveEvents = 2
|
||
// LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。
|
||
LiveSourceAuto = "auto"
|
||
LiveSourceManual = "manual"
|
||
)
|
||
|
||
// ParseLiveEventIDs 解析 display.live_events 的逗号分隔 id 列表。
|
||
//
|
||
// 非法项(非数字 / 0 / 负数)直接丢弃:这个键可能被手工改过,
|
||
// 让大屏因为一个坏 id 整块不显示,比忽略它更糟。
|
||
func ParseLiveEventIDs(raw string) []int64 {
|
||
out := make([]int64, 0, MaxLiveEvents)
|
||
seen := make(map[int64]bool)
|
||
for _, part := range strings.Split(raw, ",") {
|
||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||
if err != nil || id <= 0 || seen[id] {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
out = append(out, id)
|
||
if len(out) == MaxLiveEvents {
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FormatLiveEventIDs 把 id 列表写成 display.live_events 的存储格式(逗号分隔,最多 MaxLiveEvents 个)。
|
||
func FormatLiveEventIDs(ids []int64) string {
|
||
parts := make([]string, 0, len(ids))
|
||
for _, id := range ids {
|
||
if id <= 0 {
|
||
continue
|
||
}
|
||
parts = append(parts, strconv.FormatInt(id, 10))
|
||
if len(parts) == MaxLiveEvents {
|
||
break
|
||
}
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|
||
|
||
// NormalizeLiveSource 归一 live_source;无法识别时返回空串,由调用方决定报错还是落默认。
|
||
func NormalizeLiveSource(raw string) string {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "", LiveSourceAuto:
|
||
return LiveSourceAuto
|
||
case LiveSourceManual:
|
||
return LiveSourceManual
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// EventSettingsStandings 是榜单口径配置。
|
||
type EventSettingsStandings struct {
|
||
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
|
||
RankingMode string `json:"rankingMode"`
|
||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||
}
|
||
|
||
// WithoutAdminFields 返回非 admin 会话可见的配置子集。
|
||
//
|
||
// GET /api/settings 是 admin-only,而 /ws 快照对所有角色下发;guest/recorder
|
||
// 只需要渲染大屏的标题、主题与榜单开关,默认积分模板、审核开关与个人积分折算
|
||
// 系数属于系统配置,必须置空/清零后再上线。
|
||
// Display 整块保留:其中的公告(announce)就是给大屏底栏用的展示字段,裁剪掉它会丢公告。
|
||
func (s EventSettings) WithoutAdminFields() EventSettings {
|
||
return EventSettings{
|
||
Event: s.Event,
|
||
Display: s.Display,
|
||
Standings: EventSettingsStandings{
|
||
RankingMode: s.Standings.RankingMode,
|
||
ShowIndividualBoard: s.Standings.ShowIndividualBoard,
|
||
ShowIndividualMedals: s.Standings.ShowIndividualMedals,
|
||
},
|
||
}
|
||
}
|
||
|
||
// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关,
|
||
// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。
|
||
type RuleSwitches struct {
|
||
RecordBonusEnabled bool `json:"recordBonusEnabled"`
|
||
AdjustmentsEnabled bool `json:"adjustmentsEnabled"`
|
||
AdjustmentReasonRequired bool `json:"adjustmentReasonRequired"`
|
||
MedalCountsTeamEvents bool `json:"medalCountsTeamEvents"`
|
||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||
}
|
||
|
||
// ParseEventSettings 把 settings 表的点号键聚合成嵌套对象。
|
||
//
|
||
// 默认值必须与 REST 的 getSettings 逐条一致:大屏首包走 snapshot、管理端走 /api/settings,
|
||
// 两边口径不同会让同一块屏刷新前后显示不一样的标题。
|
||
// defaultProfileId 需要查 scoring_profiles 反查 code,属于 IO,由调用方补填。
|
||
func ParseEventSettings(values map[string]string) EventSettings {
|
||
var out EventSettings
|
||
|
||
out.Event.Name = orDefault(values["event.name"], "未命名赛事")
|
||
out.Event.Subtitle = nilIfEmpty(values["event.subtitle"])
|
||
out.Event.Host = nilIfEmpty(values["event.organizer"])
|
||
out.Event.LogoPath = nilIfEmpty(values["event.logo_path"])
|
||
out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"])
|
||
|
||
out.Display.Background = nilIfEmpty(values["display.background"])
|
||
out.Display.Logo = nilIfEmpty(values["display.logo"])
|
||
if out.Display.Logo == nil {
|
||
out.Display.Logo = out.Event.LogoPath
|
||
}
|
||
out.Display.Title = orDefault(values["display.title"], out.Event.Name)
|
||
out.Display.Subtitle = nilIfEmpty(values["display.subtitle"])
|
||
if out.Display.Subtitle == nil {
|
||
out.Display.Subtitle = out.Event.Subtitle
|
||
}
|
||
out.Display.PrimaryColor = out.Event.PrimaryColor
|
||
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
|
||
// 字体按 id 字符串原样下发,前端解析成字体栈;空串与缺键都表示用内置默认字体。
|
||
out.Display.FontTitle = nilIfEmpty(values["display.font_title"])
|
||
out.Display.FontBody = nilIfEmpty(values["display.font_body"])
|
||
// 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。
|
||
out.Display.Announce = nilIfEmpty(values["display.announce_text"])
|
||
// 实时赛况的「当前项目」:人工指定 + 判定方式,读取侧(大屏快照与管理端)口径一致。
|
||
out.Display.LiveEvents = ParseLiveEventIDs(values[SettingKeyLiveEvents])
|
||
out.Display.LiveSource = NormalizeLiveSource(values[SettingKeyLiveSource])
|
||
|
||
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
||
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
||
// showIndividualBoard 是历史别名:未单独配置时沿用旧的 show_individual_medals 取值。
|
||
out.Standings.ShowIndividualBoard = boolDefault(
|
||
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
|
||
out.Standings.ShowIndividualMedals = boolDefault(values["standings.show_individual_medals"], true)
|
||
|
||
out.RequireApproval = boolDefault(values["results.review_required"], false)
|
||
return out
|
||
}
|
||
|
||
func nilIfEmpty(s string) *string {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return &s
|
||
}
|
||
|
||
func orDefault(v, def string) string {
|
||
if strings.TrimSpace(v) == "" {
|
||
return def
|
||
}
|
||
return v
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, v := range values {
|
||
if strings.TrimSpace(v) != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func atoiDefault(v string, def int) int {
|
||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||
if err != nil {
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
func atofDefault(v string, def float64) float64 {
|
||
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||
if err != nil {
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
func boolDefault(v string, def bool) bool {
|
||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||
case "true", "1", "yes":
|
||
return true
|
||
case "false", "0", "no":
|
||
return false
|
||
default:
|
||
return def
|
||
}
|
||
}
|