Files
Scoreboard/server/internal/dto/settings.go
T
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

476 lines
20 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 dto 存放实时层与 REST 层共用的线上数据结构与纯转换函数。
//
// 放在这里而不是 httpapi:realtime 与 httpapi 是同层出口,互相 import 会造成分层倒置;
// 本包只依赖 domain 与标准库,不做任何 IO。
package dto
import (
"encoding/json"
"strconv"
"strings"
"unicode/utf8"
)
// 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"`
// Schedule 是赛事日程侧边栏(手填的日程 + 开关 + 左右与宽度 + 单屏覆盖)。
// 库里以 display.schedule 存一份 JSON,由大屏指令写入;读不出来(缺键 / 坏 JSON)时是 null,
// 大屏按「没有这个功能」处理 —— 一条脏设置绝不能让大屏整块不显示。
Schedule *ScheduleConfig `json:"schedule"`
}
// ---------- 赛事日程侧边栏 ----------
// SettingKeySchedule 是整份日程侧边栏配置在 settings 表里的键,值是一份 JSON。
//
// 与 display.announce_text 同一套机制:由 POST /api/display/command 的 schedule 指令写入
// (PUT /api/settings 是 admin-only,而现场要求录入员也能切),随 snapshot.settings 下发大屏。
// 不新建表、不写迁移。
const SettingKeySchedule = "display.schedule"
// 日程的取值上限。长度一律按**字符**(rune)算:中文标题按字节算会被切出半个字。
const (
// MaxScheduleItems 是日程条目数上限:一场赛事的日程排不下更多,也挡住「整屏全是条目」这种
// 把大屏刷爆的输入。
MaxScheduleItems = 40
// MaxScheduleTimeChars / TitleChars / NoteChars 是单项三个文本字段的字符数上限。
MaxScheduleTimeChars = 20
MaxScheduleTitleChars = 40
MaxScheduleNoteChars = 60
// MaxScheduleOverrides 是单屏覆盖的条数上限(键是实例 id)。
MaxScheduleOverrides = 50
// MaxScheduleOverrideKeyChars 是实例 id 的字符数上限,与设备自报标签同一量级。
MaxScheduleOverrideKeyChars = 120
// 侧边栏宽度按「占屏百分比」存,夹在这个区间:更窄放不下字,更宽会盖住大屏正文。
MinScheduleWidthPercent = 15
MaxScheduleWidthPercent = 40
DefaultScheduleWidthPercent = 22
)
// 侧边栏贴哪一边。认不出的取值一律回落 ScheduleSideRight(与默认口径一致)。
const (
ScheduleSideLeft = "left"
ScheduleSideRight = "right"
)
// 单屏覆盖的三态:跟随总开关 / 强制开 / 强制关。
const (
ScheduleOverrideInherit = "inherit"
ScheduleOverrideOn = "on"
ScheduleOverrideOff = "off"
)
// ScheduleItem 是日程里的一条。**手填**,与小项、报名无关,不参与算分。
//
// Time 是自由文本(「08:30」「上午」「待定」都行),**允许为空**(先排标题、时间回头补):
// 服务端不解析、不比较、不排序,列表顺序就是排定的顺序(「当前项」= 第一个 done=false 的项,
// 由大屏算)。空的只是不上屏显示时间那一格。管理端 scheduleEditor.ts 的 validateSchedule
// 同一口径:时间只卡长度,标题才必填。
// note / done 是可缺字段,缺省即「没有备注」「未完成」。
type ScheduleItem struct {
Time string `json:"time"`
Title string `json:"title"`
Note string `json:"note,omitempty"`
Done bool `json:"done,omitempty"`
}
// ScheduleConfig 是整份日程侧边栏配置。保存是**整份替换**,服务端不做局部合并
// (客户端手上就有整份配置,合并只会让两边不一致)。
//
// InstanceOverrides 按**实例 id**(GET /api/display/instances 的 id)覆盖总开关;
// 缺省即「全部 inherit」,且 `inherit` 不进 map(它就是「没有这条设置」,见下面两处解析)。
// 字段名与 web/src/shared/types.ts 的 ScheduleSidebarConfig 逐字对应。
type ScheduleConfig struct {
Items []ScheduleItem `json:"items"`
Visible bool `json:"visible"`
Side string `json:"side"`
WidthPercent float64 `json:"widthPercent"`
// ShowNote 缺省 = **true**(备注默认上屏,与管理端表单、大屏解析两边一致)。
// 缺键时不能吃 Go 的零值 false,收口在 ScheduleShowNote。
ShowNote bool `json:"showNote"`
InstanceOverrides map[string]string `json:"instanceOverrides,omitempty"`
}
// ScheduleShowNote 从原始 JSON 对象里读 showNote,缺省回落 **true**(备注默认上屏)。
//
// 为什么单独解一次而不是直接吃 ScheduleConfig 的 bool:**bool 的零值分不出「键不存在」与
// 「显式 false」**,而这两者的语义完全不同 —— 库里的 JSON 只要没带这个键(别的客户端写的、
// 手工改库、老数据),零值就会让服务端下发 false,备注在没有任何人关过它的前提下莫名不上屏。
// 管理端默认 true、大屏解析 `showNote !== false`(也是 true),服务端必须同口径。
//
// 值为 `null` 或不是布尔时同样回落 true(与前端两边的「不是 false 就当开」一致)。
// 解成 `*bool` 而不是 `bool` 正是为了这个:Go 把 JSON `null` 解进 `bool` 是**静默不动**、
// 也不报错,用值类型接就会把它当 false。
func ScheduleShowNote(raw []byte) bool {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return true
}
value, ok := obj["showNote"]
if !ok {
return true
}
var b *bool
if err := json.Unmarshal(value, &b); err != nil || b == nil {
return true
}
return *b
}
// NormalizeScheduleSide 归一左右取值:只认 left / right,其它(含空串)回落 right。
func NormalizeScheduleSide(raw string) string {
if strings.ToLower(strings.TrimSpace(raw)) == ScheduleSideLeft {
return ScheduleSideLeft
}
return ScheduleSideRight
}
// NormalizeScheduleWidth 把宽度夹到 [MinScheduleWidthPercent, MaxScheduleWidthPercent];
// 缺省(非正数)回落 DefaultScheduleWidthPercent。
//
// 夹取而不是报错:宽度越界多半是前端算错了一个数,为它让整份日程存不下去不划算,
// 而且夹到一个「能看」的值没有信息损失。
func NormalizeScheduleWidth(percent float64) float64 {
switch {
case percent <= 0:
return DefaultScheduleWidthPercent
case percent < MinScheduleWidthPercent:
return MinScheduleWidthPercent
case percent > MaxScheduleWidthPercent:
return MaxScheduleWidthPercent
}
return percent
}
// ParseScheduleValue 解析 settings 里的 display.schedule(**读入口,永不报错**)。
//
// 这个键可能被手工改过、被别的版本写过、或者写到一半断电,所以口径与 record_bonus.levels、
// ParseLiveEventIDs 一致:坏 JSON 整个当「没有这个功能」(返回 nil),结构上不成立的那几条
// (**标题**缺失的条目、非三态的覆盖、认不出的左右)丢掉,条数取前 MaxScheduleItems /
// MaxScheduleOverrides 条。**一条脏设置绝不能让大屏整块不显示**。
//
// 与写入口(httpapi 的 scheduleFromPayload)刻意不对称:那边越界报 400 而不是静默截断,
// 因为那是人在操作、要立刻知道哪一项不合规;这边面对的是已经躺在库里的数据,报错没有出口。
//
// 两处「缺省不等于零值」的收口都在这里:showNote 缺键当 **true**(见 ScheduleShowNote),
// `inherit` 的覆盖当「没有这条设置」丢掉(前端两边解析也是这么做的)。
//
// 文本只去首尾空白、不剥控制字符:写入口已经清洗过,老数据里万一还留着换行,
// 前端按普通空白渲染,不足以炸大屏;而读入口反向改写内容会掩盖数据被谁改坏过。
func ParseScheduleValue(raw string) *ScheduleConfig {
if strings.TrimSpace(raw) == "" {
return nil
}
var cfg ScheduleConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil
}
items := make([]ScheduleItem, 0, len(cfg.Items))
for _, it := range cfg.Items {
it.Time = strings.TrimSpace(it.Time)
it.Title = strings.TrimSpace(it.Title)
it.Note = strings.TrimSpace(it.Note)
// 只认标题必填:标题是列表主行,缺了就是一条空白线,整条丢掉。**时间可以为空**
// (写入口允许,管理端也允许),空串照样保留这条,大屏只是不画时间那一格。
if it.Title == "" {
continue
}
items = append(items, it)
if len(items) == MaxScheduleItems {
break
}
}
var overrides map[string]string
if len(cfg.InstanceOverrides) > 0 {
overrides = make(map[string]string, len(cfg.InstanceOverrides))
for id, value := range cfg.InstanceOverrides {
id = strings.TrimSpace(id)
if id == "" || utf8.RuneCountInString(id) > MaxScheduleOverrideKeyChars {
continue
}
// inherit 是「没有这条设置」,缺省就是它:存着只会白占 MaxScheduleOverrides
// 的条数预算(管理端与大屏解析都会把这种条目丢掉,两边不会照原样再写回来)。
switch value {
case ScheduleOverrideOn, ScheduleOverrideOff:
default:
continue
}
overrides[id] = value
if len(overrides) == MaxScheduleOverrides {
break
}
}
if len(overrides) == 0 {
overrides = nil
}
}
return &ScheduleConfig{
Items: items,
Visible: cfg.Visible,
Side: NormalizeScheduleSide(cfg.Side),
WidthPercent: NormalizeScheduleWidth(cfg.WidthPercent),
ShowNote: ScheduleShowNote([]byte(raw)),
InstanceOverrides: overrides,
}
}
// 「大屏实时赛况」的两个设置键与取值约束。
const (
// SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。
SettingKeyLiveEvents = "display.live_events"
SettingKeyLiveSource = "display.live_source"
// SettingKeyLiveOnPublish 决定发布成绩后是否立即结算:写积分流水并刷新各单位总分与奖牌榜。
// 关闭后按「分批延迟结算」处理:每次发布只落成绩,等该 (小项, 组别) 所有批次都录完才结算一次。
// 缺键 / 空值一律等于开启,保证历史赛事的现场行为不变。
SettingKeyLiveOnPublish = "standings.live_on_publish"
// 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"`
// LiveOnPublish 为 false 表示分批延迟结算:发布只落成绩,等该 (小项, 组别) 全部录完才更新榜单。
LiveOnPublish bool `json:"liveOnPublish"`
}
// 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,
// 结算方式不是系统机密,而是录入员必须知道的现场行为:
// 关掉后「发布这批成绩」不会立刻改榜单,录入页要据此提示「等全组录完」。
LiveOnPublish: s.Standings.LiveOnPublish,
},
}
}
// SessionSettings 是 GET/PUT /api/system/session 的线上结构:登录有效期(小时)。
//
// 它是**系统级**配置,存在系统库 system.db 的 settings 里,与赛事级的 EventSettings
// (event.db,随赛事包走)不共用——会话寿命是这台机器的属性,不该因为切换赛事而变。
type SessionSettings struct {
SessionHours int `json:"sessionHours"`
// DefaultHours / MinHours / MaxHours 由服务端下发,前端不再各写一份范围提示。
DefaultHours int `json:"defaultSessionHours"`
MinHours int `json:"minHours"`
MaxHours int `json:"maxHours"`
// ExpiresAt 是当前会话的到期时间(RFC3339 UTC);未登录为 null。
ExpiresAt *string `json:"expiresAt"`
}
// 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])
// 赛事日程侧边栏:由大屏指令写入(recorder 也能推),缺键 / 坏 JSON 一律 null。
out.Display.Schedule = ParseScheduleValue(values[SettingKeySchedule])
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.Standings.LiveOnPublish = boolDefault(values[SettingKeyLiveOnPublish], 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
}
}