主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。
设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 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。
476 lines
20 KiB
Go
476 lines
20 KiB
Go
// 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
|
||
}
|
||
}
|