= {}): ScheduleItem {
+ return { time, title, ...extra }
+}
+
+/** 12 条:前 6 条完成、第 7 条是当前项 —— 与示例数据同一形状,开窗用例都拿它当基准。 */
+const TWELVE: ScheduleItem[] = Array.from({ length: 12 }, (_, index) =>
+ item(`${8 + index}:00`, `第 ${index + 1} 项`, index < 6 ? { done: true } : {}),
+)
+
+test('当前项 = 第一个未勾选的条目', () => {
+ assert.equal(currentScheduleIndex(TWELVE), 6)
+ assert.equal(currentScheduleIndex(TWELVE.slice(0, 6)), -1, '全部完成时没有当前项')
+ assert.equal(currentScheduleIndex([]), -1)
+ assert.equal(currentScheduleIndex([item('08:00', '甲', { done: false })]), 0)
+})
+
+test('整体进度读数', () => {
+ assert.deepEqual(overallProgress(TWELVE), { done: 6, total: 12 })
+ assert.deepEqual(overallProgress([]), { done: 0, total: 0 })
+})
+
+test('三态:已完成 / 当前 / 未开始', () => {
+ const current = currentScheduleIndex(TWELVE)
+ assert.equal(scheduleNodeState(TWELVE[0], 0, current), 'done')
+ assert.equal(scheduleNodeState(TWELVE[5], 5, current), 'done')
+ assert.equal(scheduleNodeState(TWELVE[6], 6, current), 'current')
+ assert.equal(scheduleNodeState(TWELVE[7], 7, current), 'todo')
+ // 全部完成:没有任何节点是「当前」,最后一条也是已完成(不能把它当成当前项)。
+ assert.equal(scheduleNodeState(TWELVE[5], 5, -1), 'done')
+ assert.equal(scheduleNodeState(item('08:00', '甲'), 0, -1), 'todo')
+})
+
+test('开窗:条目少于容量时全部显示', () => {
+ const win = visibleScheduleWindow(TWELVE.slice(0, 4), 2, 8)
+ assert.deepEqual(win, { start: 0, end: 4, hiddenBefore: 0, hiddenAfter: 0 })
+})
+
+test('开窗:围绕当前项前后尽量对称', () => {
+ const win = visibleScheduleWindow(TWELVE, 6, 7)
+ assert.deepEqual(win, { start: 3, end: 10, hiddenBefore: 3, hiddenAfter: 2 })
+ assert.ok(win.start <= 6 && 6 < win.end, '当前项必须在窗口里')
+})
+
+test('开窗:当前项在第一项 / 最后一项时贴边,不留空行', () => {
+ assert.deepEqual(visibleScheduleWindow(TWELVE, 0, 5), {
+ start: 0,
+ end: 5,
+ hiddenBefore: 0,
+ hiddenAfter: 7,
+ })
+ assert.deepEqual(visibleScheduleWindow(TWELVE, 11, 5), {
+ start: 7,
+ end: 12,
+ hiddenBefore: 7,
+ hiddenAfter: 0,
+ })
+})
+
+test('开窗:全部完成(没有当前项)时贴住末尾', () => {
+ const done = TWELVE.map((one) => ({ ...one, done: true }))
+ const win = visibleScheduleWindow(done, currentScheduleIndex(done), 4)
+ assert.deepEqual(win, { start: 8, end: 12, hiddenBefore: 8, hiddenAfter: 0 })
+})
+
+test('开窗:空列表 / 容量为 0 都不崩,且窗口永不含空行', () => {
+ assert.deepEqual(visibleScheduleWindow([], 0, 5), {
+ start: 0,
+ end: 0,
+ hiddenBefore: 0,
+ hiddenAfter: 0,
+ })
+ const win = visibleScheduleWindow(TWELVE, 6, 0)
+ assert.equal(win.end - win.start, 1, '容量退化时至少显示 1 条,不能是空窗')
+ for (let capacity = 1; capacity <= 14; capacity++) {
+ const current = currentScheduleIndex(TWELVE)
+ const each = visibleScheduleWindow(TWELVE, current, capacity)
+ assert.ok(each.start <= current && current < each.end, `容量 ${capacity} 时当前项掉出窗口`)
+ assert.ok(each.end - each.start <= Math.min(12, capacity))
+ }
+})
+
+test('字号:永远落在 16–56px 硬上下限内', () => {
+ const cases: [number, number, number, number][] = [
+ [1, 900, 400, 4],
+ [40, 900, 200, 60],
+ [12, 900, 279, 29],
+ [3, 100, 60, 80],
+ [1, 100000, 100000, 1],
+ [8, 900, 279, 0],
+ ]
+ for (const [rows, available, column, em] of cases) {
+ const font = scheduleFontPx(rows, available, column, em)
+ assert.ok(font >= MIN_ROW_FONT_PX, `${rows}/${available}/${column}/${em} → ${font} 低于下限`)
+ assert.ok(font <= MAX_ROW_FONT_PX, `${rows}/${available}/${column}/${em} → ${font} 超过上限`)
+ }
+})
+
+test('字号:文本越长字号越小、行数越多字号越小', () => {
+ const short = scheduleFontPx(12, 792, 279, 8)
+ const long = scheduleFontPx(12, 792, 279, 40)
+ assert.ok(long <= short, `长文本没有收字号:${long} vs ${short}`)
+ assert.equal(long, Math.floor((279 * MAX_TITLE_LINES) / 40), '长文本按「最多 3 行」从宽度收上限')
+
+ const few = scheduleFontPx(4, 792, 279, 8)
+ const many = scheduleFontPx(20, 792, 279, 8)
+ assert.ok(many < few, `行数多没有变小:${many} vs ${few}`)
+ assert.equal(scheduleFontPx(0, 792, 279, 8), scheduleFontPx(1, 792, 279, 8), '0 行按 1 行算')
+})
+
+test('标题行数:按显示宽度算,封顶 3 行', () => {
+ assert.equal(scheduleTitleLines(4, 279, 24), 1)
+ assert.equal(scheduleTitleLines(29, 279, 24), 3, '29 字在 279px 宽 / 24px 字号下要 3 行')
+ assert.equal(scheduleTitleLines(200, 279, 24), MAX_TITLE_LINES, '再长也只允许 3 行(改截断)')
+ assert.equal(scheduleTitleLines(0, 279, 24), 1)
+})
+
+test('备注行数:总开关关掉时一行都不排', () => {
+ assert.equal(scheduleNoteLines(20, 279, 20, true), 2)
+ assert.equal(scheduleNoteLines(20, 279, 20, false), 0)
+ assert.equal(scheduleNoteLines(0, 279, 20, true), 0)
+})
+
+/** 示例数据那种「最坏情况」的量:29 字标题 + 33 字备注。 */
+const DENSE = {
+ longestTitleEm: 29,
+ longestNoteEm: 33,
+ hasTime: true,
+ showNote: true,
+}
+
+test('布局:1080p / 22% 宽,13 条长文本时的容量、字号与实际渲染条数', () => {
+ const geo = sidebarGeometry(22)
+ assert.equal(geo.panelPx, Math.round((STAGE_CONTENT_WIDTH_PX * 22) / 100))
+ assert.equal(geo.columnPx, 279, '面板 401px − 两侧内边距 60 − 时间轴 44 − 间距 18')
+ assert.equal(
+ geo.availablePx,
+ 752,
+ '保守预算:舞台内边距盒 1000 − 上下内边距 60 − 表头 88 − 两行提示 64 − 列表外边距 36',
+ )
+
+ const layout = scheduleLayout({
+ itemCount: 13,
+ availablePx: geo.availablePx,
+ columnPx: geo.columnPx,
+ ...DENSE,
+ })
+ // 排得下:条目高度之和不超过面板正文高度(少一条都不许溢出)。
+ assert.ok(
+ layout.capacity * layout.rowHeightPx + (layout.capacity - 1) * layout.rowGapPx <= geo.availablePx,
+ `排不下:容量 ${layout.capacity} × ${layout.rowHeightPx}px`,
+ )
+ assert.equal(layout.capacity, 5, '29 字标题 + 33 字备注时一屏 5 条:字号优先,宁可行数少')
+ assert.equal(layout.fontPx, 19, '贴着下限(16px)时少显示一条换来 19px 的字')
+ assert.ok(layout.fontPx >= MIN_ROW_FONT_PX && layout.fontPx <= MAX_ROW_FONT_PX)
+ assert.equal(layout.noteFontPx, Math.max(16, Math.round(layout.fontPx * 0.8)))
+ // 窗口开在当前项(第 7 条)两侧,前后各 2 条。
+ assert.deepEqual(visibleScheduleWindow(new Array(13).fill(0).map(() => item('', '')), 6, layout.capacity), {
+ start: 4,
+ end: 9,
+ hiddenBefore: 4,
+ hiddenAfter: 4,
+ })
+})
+
+test('布局:普通长度文本时 13 条显示 8 条,字号留在下限之上', () => {
+ const geo = sidebarGeometry(22)
+ const layout = scheduleLayout({
+ itemCount: 13,
+ availablePx: geo.availablePx,
+ columnPx: geo.columnPx,
+ longestTitleEm: 8,
+ longestNoteEm: 10,
+ hasTime: true,
+ showNote: true,
+ })
+ // 13 条硬塞会在 16px 上(正贴死下限),少显示几条换来 20px:
+ // 「宁可显示的行数少,也不缩到看不清」——这就是那条口径在数字上的样子。
+ assert.equal(layout.capacity, 8)
+ assert.equal(layout.fontPx, 18)
+ assert.ok(layout.fontPx > MIN_ROW_FONT_PX, '字少时不该贴在下限上')
+ assert.ok(
+ layout.capacity * layout.rowHeightPx + (layout.capacity - 1) * layout.rowGapPx <= geo.availablePx,
+ )
+})
+
+test('布局:极短文本时全部显示(放得下就不开窗)', () => {
+ const geo = sidebarGeometry(22)
+ const layout = scheduleLayout({
+ itemCount: 6,
+ availablePx: geo.availablePx,
+ columnPx: geo.columnPx,
+ longestTitleEm: 4,
+ longestNoteEm: 0,
+ hasTime: true,
+ showNote: false,
+ })
+ assert.equal(layout.capacity, 6)
+ assert.ok(layout.fontPx >= MIN_ROW_FONT_PX)
+})
+
+test('布局:条目越多容量越小,但字号绝不低于下限', () => {
+ const geo = sidebarGeometry(22)
+ const base = {
+ availablePx: geo.availablePx,
+ columnPx: geo.columnPx,
+ longestTitleEm: 29,
+ longestNoteEm: 33,
+ hasTime: true,
+ showNote: true,
+ }
+ const small = scheduleLayout({ ...base, itemCount: 6 })
+ const large = scheduleLayout({ ...base, itemCount: 40 })
+ assert.ok(small.fontPx >= large.fontPx, '条目少时字号不该更小')
+ assert.ok(large.capacity < 40, '40 条必须开窗')
+ assert.ok(large.fontPx >= MIN_ROW_FONT_PX)
+ assert.ok(
+ large.capacity * large.rowHeightPx + (large.capacity - 1) * large.rowGapPx <= geo.availablePx,
+ )
+ assert.equal(scheduleLayout({ ...base, itemCount: 0 }).capacity, 0)
+})
+
+test('布局:没有备注时不排备注行,容量相应变大', () => {
+ const geo = sidebarGeometry(22)
+ const common = {
+ itemCount: 20,
+ availablePx: geo.availablePx,
+ columnPx: geo.columnPx,
+ longestTitleEm: 12,
+ longestNoteEm: 30,
+ hasTime: true,
+ }
+ const withNote = scheduleLayout({ ...common, showNote: true })
+ const without = scheduleLayout({ ...common, showNote: false })
+ assert.ok(without.noteLines === 0 && withNote.noteLines > 0)
+ assert.ok(without.capacity >= withNote.capacity)
+})
+
+test('行高:时间 / 备注各占一行,比例固定', () => {
+ const oneLine = scheduleRowHeightPx(24, 1, 0, false)
+ const withTime = scheduleRowHeightPx(24, 1, 0, true)
+ const withNote = scheduleRowHeightPx(24, 1, 1, true)
+ assert.ok(withTime > oneLine, '有时间时条目更高')
+ assert.ok(withNote > withTime, '有备注时条目更高')
+ // 24px 标题一行:24 × 1.25 + 上下留白 24 × 0.5 = 42
+ assert.equal(oneLine, 42)
+})
+
+test('按显示宽度截断,而不是按字符数', () => {
+ assert.equal(truncateScheduleText('男子 100 米', 20), '男子 100 米')
+ // 6 个汉字正好 6em,放得下;8 个拉丁字母 6.8em 放不下,要截。
+ assert.equal(truncateScheduleText('测试测试测试', 6), '测试测试测试')
+ const cut = truncateScheduleText('IIIIIIII', 6)
+ assert.ok(cut.endsWith('…'))
+ assert.ok(textEm(cut) <= 6, `截断后仍超宽:${cut} = ${textEm(cut)}em`)
+ assert.ok(cut.length < 8, '确实切短了')
+
+ const long = truncateScheduleText('男子跳远决赛(中年组 / 青年组同场,按试跳轮次统一编排)', 4)
+ assert.ok(long.endsWith('…') && textEm(long) <= 4)
+ assert.equal(truncateScheduleText('任意文本', 0), '')
+ assert.equal(truncateScheduleText('任意文本', -1), '')
+ assert.equal(truncateScheduleText('', 10), '')
+})
+
+test('单屏覆盖三态 × 总开关:六种组合', () => {
+ const cases: [boolean, 'inherit' | 'on' | 'off', boolean][] = [
+ [true, 'inherit', true],
+ [true, 'on', true],
+ [true, 'off', false],
+ [false, 'inherit', false],
+ [false, 'on', true],
+ [false, 'off', false],
+ ]
+ for (const [visible, override, expected] of cases) {
+ const config: ScheduleSidebarConfig = {
+ items: [],
+ visible,
+ side: 'right',
+ widthPercent: 22,
+ showNote: true,
+ instanceOverrides: { screenA: override },
+ }
+ assert.equal(
+ resolveSidebarConfig(config, 'screenA').enabled,
+ expected,
+ `visible=${visible} override=${override}`,
+ )
+ }
+})
+
+test('单屏覆盖:认不出的键 = 跟随总开关,空实例 id 绝不命中', () => {
+ const config: ScheduleSidebarConfig = {
+ items: [],
+ visible: true,
+ side: 'left',
+ widthPercent: 30,
+ showNote: false,
+ instanceOverrides: { screenA: 'off', '': 'on' },
+ }
+ assert.equal(resolveSidebarConfig(config, 'screenB').enabled, true, '没配过的屏跟随总开关')
+ assert.equal(resolveSidebarConfig(config, '').enabled, true, '空 id 不许命中空键覆盖')
+ const resolved = resolveSidebarConfig(config, 'screenA')
+ assert.deepEqual(
+ { enabled: resolved.enabled, side: resolved.side, widthPercent: resolved.widthPercent, showNote: resolved.showNote },
+ { enabled: false, side: 'left', widthPercent: 30, showNote: false },
+ )
+})
+
+test('没有日程配置时:关闭 + 默认位置 / 宽度 / 备注上屏', () => {
+ for (const empty of [null, undefined]) {
+ assert.deepEqual(resolveSidebarConfig(empty, 'screenA'), {
+ enabled: false,
+ side: 'right',
+ widthPercent: 22,
+ showNote: true,
+ })
+ }
+})
+
+test('宽度百分比按服务端口径夹到 15–40,坏值退回 22', () => {
+ assert.equal(clampSidebarWidthPercent(5), 15)
+ assert.equal(clampSidebarWidthPercent(99), 40)
+ assert.equal(clampSidebarWidthPercent(22.4), 22)
+ assert.equal(clampSidebarWidthPercent(Number.NaN), 22)
+ assert.equal(clampSidebarWidthPercent(undefined), 22)
+ assert.equal(clampSidebarWidthPercent('30'), 22, '字符串不是数字,退回默认')
+})
+
+test('抑制来源:明细覆盖层 / 全屏公告', () => {
+ assert.equal(sidebarSuppressed(false, false), false)
+ assert.equal(sidebarSuppressed(true, false), true)
+ assert.equal(sidebarSuppressed(false, true), true)
+ assert.equal(sidebarSuppressed(true, true), true)
+})
+
+test('配置指纹:内容相同即相同(指令与持久值去重),内容变了就不同', () => {
+ const base: ScheduleSidebarConfig = {
+ items: [item('08:00', '甲', { done: true }), item('09:00', '乙', { note: '检录' })],
+ visible: true,
+ side: 'right',
+ widthPercent: 22,
+ showNote: true,
+ instanceOverrides: { a: 'on', b: 'off' },
+ }
+ const copy: ScheduleSidebarConfig = JSON.parse(JSON.stringify(base)) as ScheduleSidebarConfig
+ assert.equal(scheduleSignature(base), scheduleSignature(copy))
+ // 覆盖表顺序不影响指纹(服务端落库后键序可能变)。
+ const shuffled: ScheduleSidebarConfig = { ...copy, instanceOverrides: { b: 'off', a: 'on' } }
+ assert.equal(scheduleSignature(base), scheduleSignature(shuffled))
+ assert.notEqual(scheduleSignature(base), scheduleSignature({ ...copy, visible: false }))
+ assert.notEqual(scheduleSignature(base), scheduleSignature({ ...copy, showNote: false }))
+ assert.notEqual(scheduleSignature(base), scheduleSignature({ ...copy, widthPercent: 30 }))
+ assert.notEqual(
+ scheduleSignature(base),
+ scheduleSignature({ ...copy, items: [item('08:00', '甲', { done: true })] }),
+ )
+ assert.notEqual(
+ scheduleSignature(base),
+ scheduleSignature({ ...copy, items: [item('08:00', '甲', { done: true }), item('09:00', '乙', { note: '检录2' })] }),
+ )
+ assert.equal(scheduleSignature(null), '')
+ assert.equal(scheduleSignature(undefined), '')
+})
diff --git a/web/src/display/tests/schedule-wiring.test.ts b/web/src/display/tests/schedule-wiring.test.ts
new file mode 100644
index 0000000..68cbbc7
--- /dev/null
+++ b/web/src/display/tests/schedule-wiring.test.ts
@@ -0,0 +1,121 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+
+/**
+ * 接线钉子:赛事日程侧边栏的「最后一公里」。
+ *
+ * `.vue` 与 useScoreboard.ts 跑不进 node:test(仓库里没有 jsdom),
+ * 纯函数全绿也只说明算术对,接没接上必须另钉一遍。这里钉的每条都对应一个真实的半成品形态:
+ * - 面板挂错层(挂在 `.stage` 外面 → fixed 定位在带 transform 的舞台上乱飞);
+ * - 组件参与了 detail 状态机(红线 3:goto 一关明细、明细一停轮播,侧边栏跟着没了);
+ * - 忘了 `@supports` 退化(禁用毛玻璃的浏览器上白字叠在成绩上);
+ * - 指令热开关写了、设置 watcher 没写(刷新就丢);
+ * - 或者反过来,watcher 没去重(指令与持久值各来一份 → 面板闪一下)。
+ */
+const read = (path: string): string => readFileSync(new URL(path, import.meta.url), 'utf8')
+
+/** 去掉注释再断言:注释里写着「不许用 fixed」「不许出现 window」这类说明本身会被误判。 */
+const stripComments = (source: string): string =>
+ source
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(//g, '')
+ .replace(/^\s*\/\/[^\n]*$/gm, '')
+
+const APP = read('../App.vue')
+const COMPONENT = read('../components/ScheduleSidebar.vue')
+const USE_SCOREBOARD = read('../useScoreboard.ts')
+const GUARDS = read('../guards.ts')
+const PANEL = read('../schedulePanel.ts')
+/** 去掉注释后的源码:下面凡是对「不许出现某写法」的断言都看它。 */
+const PANEL_CODE = stripComments(PANEL)
+const CODE = stripComments(COMPONENT)
+
+test('侧边栏挂在 之后,且在 .stage 里', () => {
+ const overlay = APP.indexOf('')
+ const sidebar = APP.indexOf('')
+ assert.ok(overlay > 0, 'DetailOverlay 还在')
+ assert.ok(sidebar > overlay, '侧边栏必须挂在明细覆盖层之后(覆盖层在最上层)')
+ assert.equal(APP.split('').length - 1, 1, '只挂一次')
+ const stageIndex = APP.indexOf('class="stage"')
+ assert.ok(stageIndex > 0 && stageIndex < sidebar, '必须挂在 .stage 内(.stage 带 transform,外面挂会错位)')
+})
+
+test('组件容器是 absolute + inset:0 + z-index:50,绝不用 fixed', () => {
+ assert.match(COMPONENT, /\.schedule-layer\s*\{[^}]*position:\s*absolute/)
+ assert.match(COMPONENT, /\.schedule-layer\s*\{[^}]*inset:\s*0/)
+ assert.match(COMPONENT, /\.schedule-layer\s*\{[^}]*z-index:\s*50/)
+ assert.doesNotMatch(COMPONENT, /position:\s*fixed/, 'fixed 的包含块是 .stage,会被 transform 带偏')
+})
+
+test('面板几何照 DetailOverlay 参数化:左 / 右 + 宽度百分比', () => {
+ assert.match(COMPONENT, /\[side\.value\]:\s*'0'/, '左右由 side 决定')
+ assert.match(COMPONENT, /width:\s*`\$\{geometry\.value\.panelPx\}px`/)
+ assert.match(COMPONENT, /sidebarGeometry\(resolved\.value\.widthPercent\)/)
+})
+
+test('关着的时候一件 DOM 都不留', () => {
+ assert.match(COMPONENT, //)
+ assert.doesNotMatch(CODE, / {
+ assert.match(COMPONENT, /backdrop-filter:\s*blur\(/)
+ assert.match(COMPONENT, /@supports not \(\(backdrop-filter: blur\(1px\)\) or \(-webkit-backdrop-filter: blur\(1px\)\)\)/)
+ // 退化块里必须换成更不透明的深底,否则身后的榜单会穿出来把白字顶掉。
+ const fallback = COMPONENT.slice(COMPONENT.indexOf('@supports not'))
+ assert.match(fallback, /rgba\(4, 11, 24, 0\.97\)/)
+})
+
+test('主题变量与减少动效', () => {
+ for (const token of ['var(--primary)', 'var(--primary-line)', 'var(--primary-flash)', 'var(--text)', 'var(--text-dim)']) {
+ assert.ok(COMPONENT.includes(token), `要用主题变量 ${token}`)
+ }
+ assert.match(COMPONENT, /@media \(prefers-reduced-motion: reduce\)/)
+})
+
+test('侧边栏只读 detail,绝不碰明细状态机与轮播(红线 3)', () => {
+ assert.match(COMPONENT, /sidebarSuppressed\(detail\.value !== null, announceFullscreen\.value\)/)
+ for (const forbidden of ['closeDetail', 'openDetail', 'startPageRotation', 'stopPageRotation', 'detail.value =']) {
+ assert.ok(!COMPONENT.includes(forbidden), `侧边栏不许碰 ${forbidden}`)
+ }
+ // useScoreboard 的 schedule 分支也不许动明细 / 页面。
+ const branch = USE_SCOREBOARD.slice(
+ USE_SCOREBOARD.indexOf("case 'schedule'"),
+ USE_SCOREBOARD.indexOf("case 'show-detail'"),
+ )
+ assert.ok(branch.length > 0, 'handleCommand 里要有 schedule 分支')
+ for (const forbidden of ['closeDetail', 'page.value =', 'stopPageRotation']) {
+ assert.ok(!branch.includes(forbidden), `schedule 分支不许碰 ${forbidden}`)
+ }
+})
+
+test('热开关:指令分支 + 设置 watcher + 指纹去重', () => {
+ assert.match(USE_SCOREBOARD, /case 'schedule':/)
+ assert.match(USE_SCOREBOARD, /parseScheduleConfig\(command\.payload\.schedule\)/)
+ assert.match(USE_SCOREBOARD, /if \(parsed !== null\) applyScheduleConfig\(parsed\)/, '坏指令忽略,不清屏')
+ // watcher 照公告那套:设置里的持久值也当作一份来源(刷新 / 重启不丢)。
+ assert.match(USE_SCOREBOARD, /watch\(\s*\(\) => settings\.value\?\.display\?\.schedule \?\? null/)
+ assert.match(USE_SCOREBOARD, /seenScheduleSignature = scheduleSignature\(null\)/)
+ assert.match(USE_SCOREBOARD, /if \(signature === seenScheduleSignature\) return/, '同源去重')
+ // 必须真的暴露给组件,否则写得再好也没上屏。
+ assert.match(USE_SCOREBOARD, /\n\s+schedule,\n/)
+})
+
+test('设置解析层容错:display.schedule → null 也不影响整页设置', () => {
+ assert.match(GUARDS, /export function parseScheduleConfig\(value: unknown\): ScheduleSidebarConfig \| null/)
+ assert.match(GUARDS, /if \(!isRecord\(source\)\) return null/)
+ assert.match(GUARDS, /parseScheduleConfig\(settingValue\(rec, 'display\.schedule', 'display\.schedule'\)\)/)
+ assert.match(GUARDS, /schedule,\n\s*\},/, 'parseSettings 的 display 里要带上它')
+})
+
+test('排版复用 fitFont,且不按视口算(防双重缩放)', () => {
+ assert.match(PANEL, /import \{ MAX_ROW_FONT_PX, MIN_ROW_FONT_PX, fitRowFontPx, textEm \} from '\.\/fitFont'/)
+ assert.match(PANEL_CODE, /fitRowFontPx\(rows, availablePx \* HEIGHT_SAFETY, 1 \/ factor, MAX_ROW_FONT_PX\)/)
+ assert.match(PANEL_CODE, /textEm\(text\)/, '按显示宽度估宽要走 fitFont.textEm,不许另造一套')
+ // 舞台自己会 scale(),侧边栏里再按视口算一次就是双重缩放。
+ for (const forbidden of ['window.', 'innerWidth', 'innerHeight', 'vh', 'vw', 'document.']) {
+ assert.ok(!PANEL_CODE.includes(forbidden), `schedulePanel 里不许出现 ${forbidden}`)
+ }
+})
diff --git a/web/src/display/useScoreboard.ts b/web/src/display/useScoreboard.ts
index f009438..aa11aaf 100644
--- a/web/src/display/useScoreboard.ts
+++ b/web/src/display/useScoreboard.ts
@@ -7,6 +7,7 @@ import type {
Event,
EventResultGroup,
RealtimeStatus,
+ ScheduleSidebarConfig,
StandingRow,
Team,
} from '@/shared'
@@ -14,6 +15,7 @@ import {
DEFAULT_ROTATE_SECONDS,
parseAthleteStandings,
parseLiveFeed,
+ parseScheduleConfig,
parseSettings,
parseSnapshot,
parseStandingRow,
@@ -21,6 +23,7 @@ import {
parseTeamBreakdown,
} from './guards'
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
+import { scheduleSignature } from './schedulePanel'
import { entryShareIndex, entrySquadIndex } from './jointTeams'
import type { DisplayTeamShare } from './jointTeams'
import { entryRosterIndex } from './members'
@@ -80,6 +83,17 @@ const options = ref({
resultsCount: 3,
})
const detail = ref(null)
+/**
+ * 赛事日程侧边栏的当前配置(**热开关**)。
+ *
+ * 两个来源:`display:command` 的 schedule 指令(立刻生效)与设置里的 `display.schedule`
+ * (服务端持久值,刷新 / 重启不丢)。两者内容同源,靠指纹去重,见 applyScheduleConfig。
+ *
+ * 与 `detail` 状态机**完全无关**:这里只是一个配置 ref,`goto` 关明细、明细停轮播都不碰它。
+ */
+const schedule = ref(null)
+/** 上一次应用过的日程配置指纹(初始 '' = 与「没有日程」同源)。 */
+let seenScheduleSignature = scheduleSignature(null)
const booted = ref(false)
/** 公告状态机:全屏退场后正文继续留着,底栏读的就是它。 */
@@ -635,6 +649,13 @@ function handleCommand(command: DisplayCommand): void {
case 'announce_clear':
announcementStore.clear()
break
+ case 'schedule': {
+ // 热开关:整份配置即时应用,不等下一次快照(服务端随后也会把持久值推下来,指纹相同不重复应用)。
+ // 解析成 null(坏形状 / 老服务端不带这个字段)一律忽略——一条脏指令不能把已经上屏的日程清掉。
+ const parsed = parseScheduleConfig(command.payload.schedule)
+ if (parsed !== null) applyScheduleConfig(parsed)
+ break
+ }
case 'show-detail':
void openDetail(command)
break
@@ -644,6 +665,19 @@ function handleCommand(command: DisplayCommand): void {
}
}
+/**
+ * 应用一份日程配置(指令与设置两条路都走它)。
+ *
+ * 指纹相同就原地不动:服务端保存日程时「先转发指令、再落库」,落库后大屏还会收到一份内容
+ * 完全一样的设置值;不去重就会让面板重建一次(入场动效重放,看着像闪了一下)。
+ */
+function applyScheduleConfig(next: ScheduleSidebarConfig | null): void {
+ const signature = scheduleSignature(next)
+ if (signature === seenScheduleSignature) return
+ seenScheduleSignature = signature
+ schedule.value = next
+}
+
// 大屏字体随设置变化:快照、缓存、示例数据任何一条路径改动 settings 都要重新挂字体。
watch(
settings,
@@ -682,6 +716,20 @@ watch(
},
)
+/**
+ * 服务端把赛事日程持久化在设置里(display.schedule),快照与设置接口都带它。
+ *
+ * 与公告同一套理由:指令可能因为断线、重开窗口而错过,所以设置里的值也当作一份来源——
+ * 大屏刷新 / 重启后照样是上次配好的样子。指纹去重在 applyScheduleConfig 里,
+ * 所以「指令刚推过、设置又回推一份」不会让面板抖一下。
+ */
+watch(
+ () => settings.value?.display?.schedule ?? null,
+ (next) => {
+ applyScheduleConfig(next)
+ },
+)
+
// ---------------------------------------------------------------------------
// 生命周期
// ---------------------------------------------------------------------------
@@ -816,6 +864,7 @@ export function useScoreboard() {
authRequired,
options,
detail,
+ schedule,
announcement,
announceText,
booted,
diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts
index 70006f4..04ca714 100644
--- a/web/src/shared/types.ts
+++ b/web/src/shared/types.ts
@@ -681,6 +681,43 @@ export interface RecordBreakInput {
* 赛事配置。服务端以 `a.b` 形式存在 settings 表里(如 `display.rotate_seconds`),
* 接口按前缀聚合成嵌套对象,前端不必再解析点号键。
*/
+/** 赛事日程侧边栏的一条(**手填**,与小项/报名无关,不参与算分)。 */
+export interface ScheduleItem {
+ /** 时间:**自由文本**(`08:30` / `上午` / `待定` 都行)。系统不解析、不比较、不排序。 */
+ time: string
+ title: string
+ /** 备注(例如「检录 08:00」)。默认上屏,可用 config.showNote 整体关掉。 */
+ note?: string
+ /** 已完成。「当前项」= 第一个未勾选的项(不做时间比较)。 */
+ done?: boolean
+}
+
+/** 单屏覆盖的三态:跟随总开关 / 强制开 / 强制关。 */
+export type ScheduleOverride = 'inherit' | 'on' | 'off'
+
+/**
+ * 赛事日程侧边栏的整份配置。
+ *
+ * 存在**赛事库设置**里(随赛事包走),读写走 `POST /api/display/command` 的 `schedule` 动作
+ * —— 因为 `PUT /api/settings` 是 admin-only,而现场要求录入员也能切(与大屏公告同档)。
+ * 保存是**整份替换**:客户端手上就有整份配置,服务端只做校验与落库。
+ *
+ * ⚠️ 每次保存都会让全场大屏做一次全量 resync,属**低频**动作(开赛前配一次、现场偶尔勾完成)。
+ */
+export interface ScheduleSidebarConfig {
+ /** 上限 40 条(服务端校验)。 */
+ items: ScheduleItem[]
+ /** 总开关:所有大屏的默认值。 */
+ visible: boolean
+ side: 'left' | 'right'
+ /** 宽度占屏百分比,服务端夹到 15–40。 */
+ widthPercent: number
+ /** 备注是否上屏。 */
+ showNote: boolean
+ /** 按**实例 id**(GET /api/display/instances 的 id)的单屏覆盖;缺省 = inherit。 */
+ instanceOverrides?: Record
+}
+
export interface EventSettings {
event: {
name: string
@@ -718,6 +755,11 @@ export interface EventSettings {
* 旧服务端不下发时可选,解析层归一成 auto。
*/
liveSource?: string | null
+ /**
+ * 赛事日程侧边栏(手填的日程 + 开关 + 左右与宽度 + 单屏覆盖)。
+ * 旧服务端不下发这个键,所以可选;大屏解析层归一成 null(= 用内置默认:关闭)。
+ */
+ schedule?: ScheduleSidebarConfig | null
}
standings: {
individualToTeamWeight: number
@@ -1205,6 +1247,11 @@ export type DisplayAction =
| 'announce_clear'
| 'show-detail'
| 'hide-detail'
+ /**
+ * 保存赛事日程侧边栏(**整份替换**)。与大屏公告同一档:录入员及以上,
+ * 服务端在同一个请求里既转发指令给大屏、也落库到赛事设置(刷新/重启不丢)。
+ */
+ | 'schedule'
export interface DisplayCommandPayload {
page?: string
@@ -1216,6 +1263,8 @@ export interface DisplayCommandPayload {
title?: string
/** 明细由管理端算好后随命令下发,大屏只负责显示(所见即所得)。 */
detail?: TeamBreakdown
+ /** 赛事日程侧边栏的整份配置(action = 'schedule' 时必填)。 */
+ schedule?: ScheduleSidebarConfig
}
export interface DisplayCommand {