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。
This commit is contained in:
AskaEth committed 2026-09-17 20:04:50 +08:00
1 parent 56ed90ff8b
commit 9c52dc4b38
33 files changed
+4736 -10

No files matched your search

+206
View File
@@ -5,8 +5,10 @@
package dto
import (
"encoding/json"
"strconv"
"strings"
"unicode/utf8"
)
// EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构,
@@ -48,6 +50,208 @@ type EventSettingsDisplay struct {
// 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,
}
}
// 「大屏实时赛况」的两个设置键与取值约束。
@@ -204,6 +408,8 @@ func ParseEventSettings(values map[string]string) EventSettings {
// 实时赛况的「当前项目」:人工指定 + 判定方式,读取侧(大屏快照与管理端)口径一致。
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")
+196
View File
@@ -3,6 +3,7 @@ package dto
import (
"encoding/json"
"strconv"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
@@ -289,3 +290,198 @@ func TestTeamAndEventFromDomainNullableFields(t *testing.T) {
t.Fatalf("sortOrder 应保留 0: %s", eventRaw)
}
}
// ---------- 赛事日程侧边栏 ----------
// 日程存在 display.schedule 里,随 ParseEventSettings 一起下发;
// 缺键(老赛事 / 从没配过)必须是 null,前端按「没有这个功能」处理。
func TestParseEventSettingsSchedule(t *testing.T) {
out := ParseEventSettings(map[string]string{
SettingKeySchedule: `{"items":[{"time":"08:30","title":"开幕式","done":true}],` +
`"visible":true,"side":"left","widthPercent":22,"showNote":true}`,
})
if out.Display.Schedule == nil {
t.Fatal("配置了日程就应下发")
}
if !out.Display.Schedule.Visible || out.Display.Schedule.Side != ScheduleSideLeft {
t.Fatalf("日程配置应原样解析: %+v", out.Display.Schedule)
}
if len(out.Display.Schedule.Items) != 1 || out.Display.Schedule.Items[0].Title != "开幕式" {
t.Fatalf("条目应原样解析: %+v", out.Display.Schedule.Items)
}
// 时间留空的条目照样下发(只是那一条不显示时间格)。
withEmptyTime := ParseEventSettings(map[string]string{
SettingKeySchedule: `{"items":[{"time":"","title":"时间回头补"}],"visible":true}`,
})
if items := withEmptyTime.Display.Schedule.Items; len(items) != 1 || items[0].Time != "" {
t.Fatalf("时间为空的条目应照样下发: %+v", items)
}
// 这个 payload 没带 showNote:缺省的语义是 **true**(备注默认上屏),不是 Go 的零值 false。
if !withEmptyTime.Display.Schedule.ShowNote {
t.Fatal("缺 showNote 键时应缺省为 true(备注默认上屏)")
}
if got := ParseEventSettings(nil).Display.Schedule; got != nil {
t.Fatalf("没配过日程时应为 null,得到 %+v", got)
}
}
// showNote 的缺省 = **true**:bool 的零值分不出「键不存在」与「显式 false」,而两者语义相反
// ——库里那份 JSON 没带这个键(别的客户端写的 / 手工改库 / 老数据)时,服务端下发 false 会
// 让备注在没人关过它的前提下莫名不上屏。管理端默认 true、大屏解析 `!== false` 也是 true。
func TestParseScheduleValueShowNoteDefault(t *testing.T) {
for _, tc := range []struct {
name string
raw string
want bool
}{
{"缺键", `{"items":[]}`, true},
{"显式 true", `{"items":[],"showNote":true}`, true},
{"显式 false", `{"items":[],"showNote":false}`, false},
{"值为 null", `{"items":[],"showNote":null}`, true},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := ParseScheduleValue(tc.raw)
if cfg == nil {
t.Fatalf("%s 应能解析", tc.raw)
}
if cfg.ShowNote != tc.want {
t.Fatalf("%s 的 showNote 应为 %v,得到 %v", tc.raw, tc.want, cfg.ShowNote)
}
})
}
// 序列化出去也必须是显式布尔(前端按布尔读,不给 undefined)。
raw, err := json.Marshal(ParseScheduleValue(`{"items":[]}`))
if err != nil {
t.Fatalf("序列化: %v", err)
}
if !strings.Contains(string(raw), `"showNote":true`) {
t.Fatalf("缺键解析出的配置序列化时 showNote 应为 true: %s", raw)
}
// showNote 是字符串这种**类型**损坏仍走既有口径「坏 JSON 整个当没有这个功能」:
// 整份解析返回 nil(不是静默当开)。类型不对的字段与别的字段一样会解不动结构体。
if got := ParseScheduleValue(`{"items":[],"showNote":"no"}`); got != nil {
t.Fatalf("类型不对的字段当空,得到 %+v", got)
}
}
// 读入口**永不报错**:坏 JSON / 被手工改坏的值一律当空,绝不能让大屏整块不显示。
// 口径与 record_bonus.levels(rules/settings.go)、ParseLiveEventIDs 一致。
func TestParseScheduleValueTolerant(t *testing.T) {
for _, raw := range []string{
"",
" ",
"{items: [",
`"日程"`,
`{"items":[{"time":"08:30","title":"开幕`,
`[1,2,3]`,
} {
if got := ParseScheduleValue(raw); got != nil {
t.Fatalf("坏数据 %q 应当空,得到 %+v", raw, got)
}
}
// 结构上不成立的条目 / 覆盖丢掉,认不出的左右与越界宽度按读侧口径归一。
// 时间为空的条目**不丢**:写入口与管理端都允许时间留空(先排标题、回头补时间),
// 读侧把它丢掉等于静默吞掉一条操作员亲手存下来的日程。
cfg := ParseScheduleValue(`{"items":[{"time":"08:30","title":"开幕式"},{"time":"","title":"时间待定"},` +
`{"time":"09:00","title":" "},{"time":"上午","title":"趣味赛","note":"操场"}],` +
`"visible":true,"side":"up","widthPercent":999,"showNote":true,` +
`"instanceOverrides":{"browser-1":"off","browser-2":"maybe","browser-3":"inherit"}}`)
if cfg == nil {
t.Fatal("结构合法的部分应读出来")
}
if len(cfg.Items) != 3 || cfg.Items[0].Title != "开幕式" || cfg.Items[2].Note != "操场" {
t.Fatalf("只有标题为空的条目该丢掉: %+v", cfg.Items)
}
if got := cfg.Items[1]; got.Title != "时间待定" || got.Time != "" {
t.Fatalf("时间为空的条目应保留且 time 是空串: %+v", got)
}
if cfg.Side != ScheduleSideRight || cfg.WidthPercent != MaxScheduleWidthPercent {
t.Fatalf("左右应回落 right、宽度应夹到 40: %+v", cfg)
}
if !cfg.Visible || !cfg.ShowNote {
t.Fatalf("开关应原样读出: %+v", cfg)
}
// 非三态的值丢掉;`inherit` 也丢掉(它就是「没有这条设置」,留着只会白占条数预算)。
if len(cfg.InstanceOverrides) != 1 || cfg.InstanceOverrides["browser-1"] != ScheduleOverrideOff {
t.Fatalf("非三态与 inherit 的覆盖都该丢掉: %+v", cfg.InstanceOverrides)
}
// 越界的条数只取前 N 条,不报错。
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < MaxScheduleItems+1; i++ {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"time":"08:30","title":"项"}`)
}
sb.WriteString(`]}`)
if got := ParseScheduleValue(sb.String()); len(got.Items) != MaxScheduleItems {
t.Fatalf("条目应截到 %d 条,得到 %d", MaxScheduleItems, len(got.Items))
}
}
// 归一函数是读写两侧共用的口径,单独钉住边界(写侧夹取、读侧容错都走它)。
func TestNormalizeScheduleSideAndWidth(t *testing.T) {
for raw, want := range map[string]string{
"": "right", "right": "right", "left": "left", "LEFT": "left", " Left ": "left",
"up": "right", "左右": "right",
} {
if got := NormalizeScheduleSide(raw); got != want {
t.Fatalf("NormalizeScheduleSide(%q) = %q,期望 %q", raw, got, want)
}
}
for raw, want := range map[float64]float64{
0: DefaultScheduleWidthPercent, -3: DefaultScheduleWidthPercent,
1: MinScheduleWidthPercent, 14.9: MinScheduleWidthPercent,
22: 22, 40: 40, 100: MaxScheduleWidthPercent,
} {
if got := NormalizeScheduleWidth(raw); got != want {
t.Fatalf("NormalizeScheduleWidth(%v) = %v,期望 %v", raw, got, want)
}
}
}
// 线上形状:JSON 键一律小驼峰(与既有 display.* 键一致),
// items 空时是 [] 而不是 null —— 前端按数组解析,null 会当场炸。
func TestScheduleConfigJSONShape(t *testing.T) {
raw, err := json.Marshal(ScheduleConfig{
Items: []ScheduleItem{}, Side: ScheduleSideRight, WidthPercent: 22,
})
if err != nil {
t.Fatalf("序列化: %v", err)
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
t.Fatalf("解析: %v", err)
}
for _, key := range []string{"items", "visible", "side", "widthPercent", "showNote"} {
if _, ok := top[key]; !ok {
t.Fatalf("缺少小驼峰键 %s: %s", key, raw)
}
}
if string(top["items"]) != "[]" {
t.Fatalf("空日程应序列化为 []: %s", raw)
}
// 单屏覆盖可缺:全是 inherit 时不必写出来(写侧只在有内容时才建 map)。
if _, ok := top["instanceOverrides"]; ok {
t.Fatalf("没有覆盖时不该出现 instanceOverrides: %s", raw)
}
// 可缺字段:note / done 缺省时不下线。
itemRaw, err := json.Marshal(ScheduleItem{Time: "08:30", Title: "开幕式"})
if err != nil {
t.Fatalf("序列化条目: %v", err)
}
var item map[string]json.RawMessage
if err := json.Unmarshal(itemRaw, &item); err != nil {
t.Fatalf("解析条目: %v", err)
}
if len(item) != 2 {
t.Fatalf("缺省字段不该上线,得到 %s", itemRaw)
}
}