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

+536 -1
View File
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { formatDateTime, type EventSettings, type LiveEvents, type NetworkInfo } from '@/shared'
import {
formatDateTime,
type EventSettings,
type LiveEvents,
type NetworkInfo,
type ScheduleOverride,
type ScheduleSidebarConfig,
} from '@/shared'
import type { DisplayInstance } from '@/shared/api'
import { DISPLAY_PAGES, type DisplayPageKey } from '@/display/types'
import { encodeQr, qrToSvg } from '@/shared/qrcode'
@@ -32,6 +39,32 @@ import {
toggleQr,
writeManualAddresses,
} from './lanDisplay'
import {
SCHEDULE_MAX_ITEMS,
SCHEDULE_NOTE_MAX,
SCHEDULE_OVERRIDE_OPTIONS,
SCHEDULE_SIDE_OPTIONS,
SCHEDULE_TIME_MAX,
SCHEDULE_TITLE_MAX,
SCHEDULE_WIDTH_MAX,
SCHEDULE_WIDTH_MIN,
emptyScheduleErrors,
emptyScheduleRow,
hasScheduleErrors,
hiddenOverrideCount,
moveScheduleRow,
normalizeScheduleConfig,
overrideOf,
overrideTargets,
rowsFromSchedule,
scheduleCommand,
scheduleConfigFrom,
scheduleSummary,
validateSchedule,
withOverride,
type ScheduleErrors,
type ScheduleRow,
} from './scheduleEditor'
/**
* 页面选项直接取大屏那份清单(`@/display/types`):规范页名与中文标签只维护一处,
@@ -158,6 +191,12 @@ async function saveLive(): Promise<void> {
}
}
/**
* 读一次完整设置,把本页三张卡片(公告 / 实时赛况 / 赛事日程)的本地状态对齐到服务端。
*
* 日程那半边由别处推过也会改到(另一台管理面板、或服务端夹紧了宽度),所以这里同样以
* 服务端返回的那份为准;**只在挂载时跑一次**,编辑中的行不会被后台刷新冲掉。
*/
async function refreshSettings(): Promise<void> {
try {
const settings = await adminApi.client.settings.get()
@@ -165,6 +204,7 @@ async function refreshSettings(): Promise<void> {
currentAnnounce.value = settings.display?.announce ?? null
liveSource.value = settings.display?.liveSource === 'manual' ? 'manual' : 'auto'
liveEvents.value = [...(settings.display?.liveEvents ?? [])]
applyScheduleConfig(settings.display?.schedule ?? null)
} catch {
// 示例数据 / 断网时读不到设置,保留本地这一份,不能把「当前公告」清成未知。
}
@@ -678,6 +718,127 @@ const liveGateReason = computed(() => {
return null
})
// ── 赛事日程侧边栏(手填日程 + 总开关 + 单屏覆盖) ────────────────────────────
/**
* 这张卡片为什么走 `POST /api/display/command` 而不是 `PUT /api/settings`。
*
* 上方那张「实时赛况」卡是 admin-only 的(settings 接口只有管理员能写),所以它老老实实
* 给非管理员置灰;日程这条需求反过来点名了「录入员及以上」,服务端也因此把它放在
* display:command(与大屏公告同一条路,那条路本来就是 recorder 档)。**不要**顺手改成
* settingsApi.save —— 那不是「更一致」,而是把录入员的保存变成必然 403。
*/
const scheduleRows = ref<ScheduleRow[]>([])
const scheduleVisible = ref(false)
const scheduleSide = ref<'left' | 'right'>('right')
/** 宽度:输入框清空时是空串(`v-model.number` 保持原值),校验按「非数字」拦下。 */
const scheduleWidth = ref<number | string>(22)
const scheduleShowNote = ref(true)
/** 键是**实例 id**(与大屏手动别名同一把钥匙),不是屏号也不是下标。 */
const scheduleOverrides = ref<Record<string, ScheduleOverride>>({})
const scheduleErrors = ref<ScheduleErrors>(emptyScheduleErrors())
const scheduleSaving = ref(false)
/** 行 key 单调递增:删一行再加一行不会撞 key(撞了红字与 v-for 都会跟错行)。 */
let scheduleRowSeq = 0
function takeScheduleKey(): number {
scheduleRowSeq += 1
return scheduleRowSeq
}
/** 服务端那份配置 → 界面状态;读不到(老服务端 / 坏 JSON)时按默认值收下。 */
function applyScheduleConfig(raw: ScheduleSidebarConfig | null | undefined): void {
const config = normalizeScheduleConfig(raw)
scheduleRows.value = rowsFromSchedule(config, () => takeScheduleKey())
scheduleVisible.value = config.visible
scheduleSide.value = config.side
scheduleWidth.value = config.widthPercent
scheduleShowNote.value = config.showNote
scheduleOverrides.value = { ...(config.instanceOverrides ?? {}) }
scheduleErrors.value = emptyScheduleErrors()
}
// 先拿启动时已经加载到的那份设置垫上,避免刷新前卡片空着;refreshSettings 随后会核对一次。
applyScheduleConfig(data.settings?.display?.schedule ?? null)
/** 非 null 表示整张卡片是只读的;与「改名」按钮同一口径(录入员及以上 = canWrite)。 */
const scheduleGateReason = computed(() =>
canWrite.value ? null : auth.readOnly ? '请先登录后再修改赛事日程。' : '服务端未连接,先恢复连接再修改赛事日程。',
)
/** 权限按下去了、或正在提交时,输入控件一起禁用(提交中改一行会让发出去的与看到的不一致)。 */
const scheduleLocked = computed(() => scheduleGateReason.value !== null || scheduleSaving.value)
/** 列表只列在线实例 + 身上挂着非 inherit 覆盖的离线实例(见 scheduleEditor 里的解释)。 */
const scheduleTargets = computed(() => overrideTargets(instances.value, scheduleOverrides.value))
/** 列表里已经没有的覆盖条数:设置照常提交,只是说明一句,别让人以为清掉了。 */
const scheduleHiddenOverrides = computed(() => hiddenOverrideCount(instances.value, scheduleOverrides.value))
/** 底部那几句与行无关的校验红字(条数上限、宽度越界)。 */
const scheduleFormErrors = computed(() => scheduleErrors.value.form)
function addScheduleRow(): void {
scheduleRows.value = [...scheduleRows.value, emptyScheduleRow(takeScheduleKey())]
}
function removeScheduleRow(key: number): void {
scheduleRows.value = scheduleRows.value.filter((row) => row.key !== key)
}
/** 上移 / 下移:越界时 moveScheduleRow 原样返回同一个引用,这里也就不会产生任何变更。 */
function moveRow(index: number, delta: number): void {
scheduleRows.value = moveScheduleRow(scheduleRows.value, index, delta)
}
/** 三态下拉:只认选项表里的值,别的(选中项被程序改坏)当没发生。 */
function pickScheduleOverride(instanceId: string, event: Event): void {
const value = (event.target as HTMLSelectElement).value
if (!SCHEDULE_OVERRIDE_OPTIONS.some((option) => option.value === value)) return
scheduleOverrides.value = withOverride(scheduleOverrides.value, instanceId, value as ScheduleOverride)
}
async function saveSchedule(): Promise<void> {
if (!canWrite.value) {
toast.error('当前不可保存赛事日程', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (data.isMock) {
// 示例数据模式下 callWrite 会直接抛错;先给一句人话,别让人对着「操作失败」猜原因。
toast.warning('示例数据模式未真正保存', '后端未就绪,改动不会写入赛事库')
return
}
const width = Number(String(scheduleWidth.value).trim())
const errors = validateSchedule(scheduleRows.value, scheduleWidth.value)
scheduleErrors.value = errors
if (hasScheduleErrors(errors)) {
// 本地先拦下:不合规就标红并不提交,不等服务端 400(那边只回一句话,指不到是哪一行)。
toast.error('赛事日程校验未通过', '请先修正标红的字段')
return
}
const config = scheduleConfigFrom(scheduleRows.value, {
visible: scheduleVisible.value,
side: scheduleSide.value,
widthPercent: width,
showNote: scheduleShowNote.value,
instanceOverrides: scheduleOverrides.value,
})
scheduleSaving.value = true
try {
// 整份替换 + 广播:客户端手上就有整份配置,服务端只做校验与落库,不做局部合并。
await adminApi.displayCommand(scheduleCommand(config))
toast.success(
`已把赛事日程改成「${scheduleSummary(config)}」`,
'大屏立刻生效;配置随赛事库保存,刷新与重启都不会丢',
)
} catch (caught) {
// 服务端原文(条数超限 / 字段过长 / 权限不足)必须让操作员看见,不能吞成一句「保存失败」。
toast.error('保存赛事日程失败', caught instanceof Error ? caught.message : null)
} finally {
scheduleSaving.value = false
}
}
/**
* 实例的显示名:有屏号用屏号,局域网浏览器没有屏号,退回来源 IP,都用不上才退回实例 id。
* 只用于提示语,表格里始终显示完整 id。
@@ -947,6 +1108,246 @@ async function send(
</div>
</section>
<!--
赛事日程侧边栏:手填日程 + 总开关 + 每块屏三态覆盖。
保存走 adminApi.displayCommand(display:command 的 schedule 动作,录入员及以上),
见 saveSchedule —— 千万别改成 settingsApi.save(那是 admin-only)。
排序只有上移 / 下移两个按钮,不引入任何拖拽:本页已有的测试明令禁止
pointerdown / mousedown / touchstart 监听(按下的那一刻重排会吞掉 click)。
-->
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">赛事日程</span>
<span class="mx-badge" :class="scheduleVisible ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ scheduleRows.length }} 条 · 默认{{ scheduleVisible ? '开启' : '关闭' }}
</span>
</div>
<div class="mx-card-body">
<p v-if="scheduleGateReason !== null" class="notice">{{ scheduleGateReason }}</p>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact sched-table">
<thead>
<tr>
<th class="sched-cell-time">时间</th>
<th>标题</th>
<th>备注</th>
<th class="sched-cell-done">已完成</th>
<th class="right sched-cell-actions">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in scheduleRows" :key="row.key">
<td>
<input
v-model="row.time"
class="mx-cell-input"
:class="{ 'is-invalid': scheduleErrors.rows[row.key]?.time }"
type="text"
:maxlength="SCHEDULE_TIME_MAX"
placeholder="08:30"
:disabled="scheduleLocked"
/>
<span v-if="scheduleErrors.rows[row.key]?.time" class="row-error">
{{ scheduleErrors.rows[row.key]?.time }}
</span>
</td>
<td>
<input
v-model="row.title"
class="mx-cell-input"
:class="{ 'is-invalid': scheduleErrors.rows[row.key]?.title }"
type="text"
:maxlength="SCHEDULE_TITLE_MAX"
placeholder="例如 男子 100 米决赛"
:disabled="scheduleLocked"
/>
<span v-if="scheduleErrors.rows[row.key]?.title" class="row-error">
{{ scheduleErrors.rows[row.key]?.title }}
</span>
</td>
<td>
<input
v-model="row.note"
class="mx-cell-input"
:class="{ 'is-invalid': scheduleErrors.rows[row.key]?.note }"
type="text"
:maxlength="SCHEDULE_NOTE_MAX"
placeholder="检录 08:00(可留空)"
:disabled="scheduleLocked"
/>
<span v-if="scheduleErrors.rows[row.key]?.note" class="row-error">
{{ scheduleErrors.rows[row.key]?.note }}
</span>
</td>
<td class="sched-cell-done">
<label class="mx-switch sched-done-switch">
<input v-model="row.done" type="checkbox" :disabled="scheduleLocked" />
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
</label>
</td>
<td class="sched-cell-actions">
<div class="sched-actions">
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="index === 0 || scheduleLocked"
title="与上一条交换顺序"
@click="moveRow(index, -1)"
>
上移
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="index === scheduleRows.length - 1 || scheduleLocked"
title="与下一条交换顺序"
@click="moveRow(index, 1)"
>
下移
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm danger-text"
type="button"
:disabled="scheduleLocked"
@click="removeScheduleRow(row.key)"
>
删除
</button>
</div>
</td>
</tr>
<tr v-if="scheduleRows.length === 0">
<td colspan="5">
<div class="mx-empty">
<div class="mx-empty-title">还没有日程条目</div>
<div class="mx-empty-desc">点下面的「新增一条」手填时间与标题;不填就没有侧边栏内容</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="sched-foot-row">
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="scheduleLocked || scheduleRows.length >= SCHEDULE_MAX_ITEMS"
:title="`最多 ${SCHEDULE_MAX_ITEMS} 条`"
@click="addScheduleRow"
>
<MxIcon name="entry" :size="15" /> 新增一条
</button>
<span class="sched-count">共 {{ scheduleRows.length }} / {{ SCHEDULE_MAX_ITEMS }} 条</span>
</div>
<p v-for="message in scheduleFormErrors" :key="message" class="mx-helper mx-helper-error sched-form-error">
{{ message }}
</p>
<div class="sched-switches">
<div class="sched-switch-field">
<span class="mx-label">侧边栏默认开启</span>
<span class="sched-switch-row">
<label class="mx-switch">
<input v-model="scheduleVisible" type="checkbox" :disabled="scheduleLocked" />
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
</label>
<span class="sched-switch-text">{{ scheduleVisible ? '开(所有大屏默认显示)' : '关(所有大屏默认隐藏)' }}</span>
</span>
</div>
<div class="sched-switch-field">
<span class="mx-label">备注上屏</span>
<span class="sched-switch-row">
<label class="mx-switch">
<input v-model="scheduleShowNote" type="checkbox" :disabled="scheduleLocked" />
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
</label>
<span class="sched-switch-text">{{ scheduleShowNote ? '显示备注' : '只显示时间与标题' }}</span>
</span>
</div>
</div>
<div class="sched-geometry">
<label class="sched-geometry-field">
<span class="mx-label">位置</span>
<select v-model="scheduleSide" class="mx-cell-input sched-side-select" :disabled="scheduleLocked">
<option v-for="option in SCHEDULE_SIDE_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="sched-geometry-field">
<span class="mx-label">宽度</span>
<input
v-model.number="scheduleWidth"
class="mx-cell-input sched-width-input"
type="number"
:min="SCHEDULE_WIDTH_MIN"
:max="SCHEDULE_WIDTH_MAX"
:disabled="scheduleLocked"
/>
<span class="sched-switch-text">%</span>
</label>
</div>
<p class="hint">
宽度是占整屏的百分比({{ SCHEDULE_WIDTH_MIN }}–{{ SCHEDULE_WIDTH_MAX }})。大屏上是一块
<strong>深色半透明</strong>面板叠在当前页之上:不改当前页、不停轮播,明细与全屏公告期间自动收起。
</p>
<div class="sched-overrides">
<div class="sched-overrides-title">每块屏单独设置(覆盖上面的总开关)</div>
<p v-if="scheduleTargets.length === 0" class="display-note">
还没有大屏上报,暂时没有可单独设置的设备;新设备接上后回到这里即可。
</p>
<div v-for="instance in scheduleTargets" :key="instance.id" class="sched-override-row">
<div class="sched-override-name" :title="instanceTooltip(instance)">
<span class="instance-name">{{ instanceDisplayName(instance) }}</span>
<span v-if="instanceIdNote(instance) !== null" class="instance-id-note">{{ instanceIdNote(instance) }}</span>
<span class="mx-badge" :class="instance.online ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ instance.online ? '在线' : '离线' }}
</span>
</div>
<select
class="mx-cell-input sched-override-select"
:value="overrideOf(scheduleOverrides, instance.id)"
:disabled="scheduleLocked"
:aria-label="`${instanceDisplayName(instance)} 的侧边栏设置`"
@change="pickScheduleOverride(instance.id, $event)"
>
<option v-for="option in SCHEDULE_OVERRIDE_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<p v-if="scheduleHiddenOverrides > 0" class="display-note">
另有 {{ scheduleHiddenOverrides }} 条单屏设置对应的设备已不在列表里:设置会照原样保留并随本次保存一起提交。
</p>
</div>
<div class="announce-foot">
<span class="sched-save-note">保存 = 整份替换并立刻推给所有大屏</span>
<div class="announce-actions">
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="scheduleLocked"
:title="scheduleGateReason ?? '保存后大屏立刻生效'"
@click="saveSchedule"
>
<MxIcon name="check" :size="15" />
{{ scheduleSaving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<p class="hint">
条目顺序就是大屏上从上到下的顺序(手填时间不参与排序);勾「已完成」的条目在大屏上淡化保留。
每次保存都会让全场大屏重新同步一次,属低频动作:开赛前配一次、现场偶尔勾一下完成。
</p>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">实时赛况</span>
@@ -1741,6 +2142,140 @@ async function send(
color: var(--mx-t3);
line-height: 1.6;
}
/* ── 赛事日程卡片 ─────────────────────────────────────────────────────────── */
/* 时间列很窄(「08:30」四个字符),标题 / 备注吃掉剩余宽度。 */
.sched-cell-time {
width: 96px;
}
.sched-cell-done {
width: 76px;
text-align: center;
}
/*
* 操作列定宽 + 不换行:三个按钮(上移 / 下移 / 删除)挤在 auto 布局的窄格里会各自换行,
* 一行的按钮竖着摞成一列,整张表被撑到 100px 以上的行高(真 Chromium 里量到过)。
*/
.sched-cell-actions {
width: 176px;
min-width: 176px;
white-space: nowrap;
}
.sched-actions {
display: flex;
gap: 6px;
flex-wrap: nowrap;
justify-content: flex-end;
}
/* 行内红字:与 baseData 各表同一套写法(11px、紧贴字段下方)。 */
.row-error {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--mx-red);
}
.danger-text {
color: var(--mx-red);
}
.sched-foot-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
}
.sched-count {
font-size: 12px;
color: var(--mx-t3);
}
/* 与行无关的校验红字(条数上限、宽度越界),和「新增一条」那一行拉开一点。 */
.sched-form-error {
margin-top: 8px;
}
.sched-switches {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--mx-border);
}
.sched-switch-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sched-switch-row {
display: flex;
align-items: center;
gap: 8px;
}
.sched-switch-text {
font-size: 12px;
color: var(--mx-t3);
}
/* 位置与宽度:两个窄控件并排,窄屏下换行。 */
.sched-geometry {
display: flex;
align-items: center;
gap: 16px;
margin-top: 12px;
flex-wrap: wrap;
}
.sched-geometry-field {
display: flex;
align-items: center;
gap: 8px;
}
.sched-side-select {
width: 96px;
}
.sched-width-input {
width: 80px;
text-align: center;
}
/* 每块屏的三态覆盖:一行一块屏,名字左、下拉右。 */
.sched-overrides {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--mx-border);
}
.sched-overrides-title {
font-size: 13px;
font-weight: 600;
color: var(--mx-t2);
margin-bottom: 8px;
}
.sched-override-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.sched-override-row + .sched-override-row {
margin-top: 8px;
}
.sched-override-name {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
font-size: 13px;
font-weight: 600;
}
/* 下拉是定宽的:三态文案长度差得多,跟随内容会让整列在切换时抖动。 */
.sched-override-select {
width: 132px;
flex: none;
}
.sched-save-note {
font-size: 12px;
color: var(--mx-t3);
}
@media (max-width: 1023px) {
.display-grid {
grid-template-columns: 1fr;
+327
View File
@@ -0,0 +1,327 @@
import type { DisplayCommand, ScheduleItem, ScheduleOverride, ScheduleSidebarConfig } from '@/shared'
import type { DisplayInstance } from '@/shared/api'
/**
* 「赛事日程」卡片的纯逻辑。
*
* 抽出来是因为组件在仓库里跑不起来(没有 jsdom):默认值、本地校验、上下移的边界、
* 三态覆盖的读写(切回「跟随总开关」必须真把键删掉)、请求体组装——这几件事写错的后果
* 都不是编译期能发现的:要么现场存进去一份服务端不认的形状,要么列表里留下
* `{"<实例 id>": "inherit"}` 这种「看着是默认、实际多一条」的垃圾,要么上移按到第一条上
* 把顺序悄悄改乱。
*
* 校验口径与服务端(`realtime/protocol.go` + `dto/settings.go`)逐条对齐:条数 ≤ 40、
* time ≤ 20 / title ≤ 40 / note ≤ 60(**按字符**,不是 UTF-16 码元)、宽度 15–40。
* 本地先拦一道是为了「标红并不提交」,而不是等服务端回 400 再让人猜哪一条不对。
*
* ⚠️ 保存走 `POST /api/display/command` 的 `schedule` 动作(录入员及以上),
* **不能走 `PUT /api/settings`** —— 那条是 admin-only,会把「录入员也能切」这条需求打死。
*/
/** 条目数上限(与服务端同一口径)。 */
export const SCHEDULE_MAX_ITEMS = 40
export const SCHEDULE_TIME_MAX = 20
export const SCHEDULE_TITLE_MAX = 40
export const SCHEDULE_NOTE_MAX = 60
export const SCHEDULE_WIDTH_MIN = 15
export const SCHEDULE_WIDTH_MAX = 40
/** 位置选项;文案只在这里维护一处,模板与提示语都用它。 */
export const SCHEDULE_SIDE_OPTIONS: ReadonlyArray<{ value: 'left' | 'right'; label: string }> = [
{ value: 'left', label: '左侧' },
{ value: 'right', label: '右侧' },
]
/** 单屏覆盖的三态选项。顺序固定:默认项在最前,现场一眼看到的是「没特殊设置」。 */
export const SCHEDULE_OVERRIDE_OPTIONS: ReadonlyArray<{ value: ScheduleOverride; label: string }> = [
{ value: 'inherit', label: '跟随总开关' },
{ value: 'on', label: '强制开' },
{ value: 'off', label: '强制关' },
]
/** 读不到配置时用的那一份:侧边栏关闭、右侧 22%、备注上屏。 */
export function defaultScheduleConfig(): ScheduleSidebarConfig {
return { items: [], visible: false, side: 'right', widthPercent: 22, showNote: true }
}
/**
* 按**字符**数长度:中文一个字算一个,emoji 也算一个。
*
* 不能直接用 `text.length`(UTF-16 码元数):一个 emoji 在它眼里是 2,服务端按 rune 算是 1,
* 于是本地会把「服务端明明收得下」的标题标红。两边口径必须一致。
*/
export function charCount(text: string): number {
return [...text].length
}
/** 宽度夹到 15–40;非数字退回默认值(解析层兜底用,本地校验仍然拦越界)。 */
export function clampWidthPercent(value: unknown): number {
const base = defaultScheduleConfig().widthPercent
const width = typeof value === 'number' ? value : Number.NaN
if (!Number.isFinite(width)) return base
return Math.min(SCHEDULE_WIDTH_MAX, Math.max(SCHEDULE_WIDTH_MIN, width))
}
function normalizeItem(raw: unknown): ScheduleItem | null {
if (raw === null || typeof raw !== 'object') return null
const item = raw as Partial<ScheduleItem>
// 标题是列表主行,没有标题的条目在大屏上就是一条空白线;服务端不会下发这种数据,
// 真碰上了(手工改库 / 老版本写的坏 JSON)就当这条不存在,而不是渲染一条空行。
if (typeof item.title !== 'string') return null
const out: ScheduleItem = {
time: typeof item.time === 'string' ? item.time : '',
title: item.title,
done: item.done === true,
}
if (typeof item.note === 'string' && item.note !== '') out.note = item.note
return out
}
function normalizeOverrides(raw: unknown): Record<string, ScheduleOverride> {
if (raw === null || typeof raw !== 'object') return {}
const out: Record<string, ScheduleOverride> = {}
for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
// `inherit` 是「没有这条设置」,落库时不该留着键(否则每次保存都在长垃圾)。
if (value === 'on' || value === 'off') out[id] = value
}
return out
}
/**
* 把服务端下发的那份配置收成界面能用的形状。
*
* 老服务端没有这个键(`undefined`)、坏 JSON(不是对象)、缺字段都能收下:一律按默认值补,
* 绝不抛异常——管理面板读设置失败会把整页的公告 / 实时赛况一起拖下水。
*/
export function normalizeScheduleConfig(raw: ScheduleSidebarConfig | null | undefined): ScheduleSidebarConfig {
const base = defaultScheduleConfig()
if (raw === null || raw === undefined || typeof raw !== 'object') return base
const items = Array.isArray(raw.items)
? raw.items
.slice(0, SCHEDULE_MAX_ITEMS)
.map(normalizeItem)
.filter((item): item is ScheduleItem => item !== null)
: []
return {
items,
visible: raw.visible === true,
side: raw.side === 'left' ? 'left' : 'right',
widthPercent: clampWidthPercent(raw.widthPercent),
showNote: typeof raw.showNote === 'boolean' ? raw.showNote : base.showNote,
instanceOverrides: normalizeOverrides(raw.instanceOverrides),
}
}
// ── 行编辑 ──────────────────────────────────────────────────────────────────
/**
* 表格里的一行。
*
* `key` 是行标识(v-for 的 key 与行内红字的下标都是它),**不是**数组下标:
* 上下移 / 删行之后下标会整体挪位,拿下标当 key 会让红字跟错行(改完 A 行、红字跑到 B 行)。
*/
export interface ScheduleRow {
key: number
time: string
title: string
note: string
done: boolean
}
/** 配置 → 行;`keyOf` 由页面给(页面持有一个单调递增的计数器,保证新增 / 载入都不撞 key)。 */
export function rowsFromSchedule(
config: ScheduleSidebarConfig,
keyOf: (index: number) => number = (index) => index + 1,
): ScheduleRow[] {
return config.items.map((item, index) => ({
key: keyOf(index),
time: item.time,
title: item.title,
note: item.note ?? '',
done: item.done === true,
}))
}
/** 新增的空行;`done` 默认未完成(刚排上的日程当然是没开始的)。 */
export function emptyScheduleRow(key: number): ScheduleRow {
return { key, time: '', title: '', note: '', done: false }
}
/**
* 上移 / 下移一行。
*
* 越界(第一条上移、最后一条下移)**原样返回同一个数组引用**:页面把结果写回 ref 时,
* Vue 靠引用判断有没有变,返回一个新数组会让「点了没反应」也触发一次重渲染;
* 而且这几条边界在测试里就是靠 `===` 钉住的。
* 真正移动时返回**新数组**(不改原数组),原地改动会让 Vue 的依赖追踪漏掉这次变更。
*/
export function moveScheduleRow(rows: ScheduleRow[], index: number, delta: number): ScheduleRow[] {
const target = index + delta
if (index < 0 || index >= rows.length) return rows
if (target < 0 || target >= rows.length) return rows
const next = [...rows]
const current = next[index]
const swap = next[target]
if (current === undefined || swap === undefined) return rows
next[index] = swap
next[target] = current
return next
}
// ── 本地校验 ────────────────────────────────────────────────────────────────
/** 一行的行内红字;与字段一一对应,缺的字段不出现。 */
export interface ScheduleRowError {
time?: string
title?: string
note?: string
}
export interface ScheduleErrors {
/** 行内错误,按行 key 索引。 */
rows: Record<number, ScheduleRowError>
/** 与具体行无关的问题(条数上限、宽度越界),按顺序列在卡片底部。 */
form: string[]
}
export function emptyScheduleErrors(): ScheduleErrors {
return { rows: {}, form: [] }
}
/** 有没有任何一条不合规;保存前的唯一判据(不合规就标红、不提交,不等服务端 400)。 */
export function hasScheduleErrors(errors: ScheduleErrors): boolean {
return errors.form.length > 0 || Object.keys(errors.rows).length > 0
}
/** 宽度文案:非法输入(空 / 非数字 / 越界)都要报同一句,别让人猜是哪一种。 */
export const SCHEDULE_WIDTH_ERROR = `宽度为 ${SCHEDULE_WIDTH_MIN}–${SCHEDULE_WIDTH_MAX} 之间的数字`
export function validateSchedule(rows: ScheduleRow[], widthPercent: number | string): ScheduleErrors {
const errors = emptyScheduleErrors()
if (rows.length > SCHEDULE_MAX_ITEMS) {
errors.form.push(`最多 ${SCHEDULE_MAX_ITEMS} 条日程(当前 ${rows.length} 条)`)
}
// 空串(输入框被清空)与越界走同一句:`Number('')` 是 0,会被下面的范围判定拦下。
const width = typeof widthPercent === 'number' ? widthPercent : Number(String(widthPercent).trim())
if (!Number.isFinite(width) || width < SCHEDULE_WIDTH_MIN || width > SCHEDULE_WIDTH_MAX) {
errors.form.push(SCHEDULE_WIDTH_ERROR)
}
rows.forEach((row) => {
const rowError: ScheduleRowError = {}
const title = row.title.trim()
const time = row.time.trim()
const note = row.note.trim()
if (title === '') rowError.title = '标题必填'
else if (charCount(title) > SCHEDULE_TITLE_MAX) rowError.title = `标题最多 ${SCHEDULE_TITLE_MAX} 字`
// 时间与备注都可空(时间可以是「上午」「待定」,甚至先留空回头再填),只卡长度。
if (charCount(time) > SCHEDULE_TIME_MAX) rowError.time = `时间最多 ${SCHEDULE_TIME_MAX} 字`
if (charCount(note) > SCHEDULE_NOTE_MAX) rowError.note = `备注最多 ${SCHEDULE_NOTE_MAX} 字`
if (Object.keys(rowError).length > 0) errors.rows[row.key] = rowError
})
return errors
}
// ── 组装 ────────────────────────────────────────────────────────────────────
/** 行 → 条目:首尾空白在这里去掉(粘贴进来的时间几乎都带空格),备注为空时整键不出现。 */
export function itemFromRow(row: ScheduleRow): ScheduleItem {
const item: ScheduleItem = { time: row.time.trim(), title: row.title.trim(), done: row.done }
const note = row.note.trim()
if (note !== '') item.note = note
return item
}
export interface ScheduleFormState {
visible: boolean
side: 'left' | 'right'
widthPercent: number
showNote: boolean
instanceOverrides: Record<string, ScheduleOverride>
}
/**
* 行 + 开关 → 整份配置(**整份替换**:客户端手上就有整份,服务端不做局部合并)。
*
* 条目的顺序就是大屏上从上到下的顺序,所以这里绝不做任何排序 —— 手填的时间是自由文本
* (「上午」「待定」),排一次就是把用户亲手排的顺序改掉。
*/
export function scheduleConfigFrom(rows: ScheduleRow[], form: ScheduleFormState): ScheduleSidebarConfig {
return {
items: rows.map(itemFromRow),
visible: form.visible,
side: form.side,
widthPercent: form.widthPercent,
showNote: form.showNote,
instanceOverrides: { ...form.instanceOverrides },
}
}
/** 保存请求体:整份配置 + `targets: []`(空 = 广播给所有大屏,与大屏公告同一条路)。 */
export function scheduleCommand(config: ScheduleSidebarConfig): DisplayCommand {
return { action: 'schedule', payload: { schedule: config }, targets: [] }
}
// ── 单屏三态覆盖 ────────────────────────────────────────────────────────────
/** 某块屏当前的三态;没有这条键(绝大多数情况)就是「跟随总开关」。 */
export function overrideOf(
overrides: Record<string, ScheduleOverride> | undefined,
instanceId: string,
): ScheduleOverride {
const value = overrides?.[instanceId]
return value === 'on' || value === 'off' ? value : 'inherit'
}
/**
* 写入某块屏的三态。
*
* 切回 `inherit` 时**真的删掉那个键**(不是写一个 `'inherit'` 进去):服务端把它当
* 「没有这条设置」,留着键只会让 `instanceOverrides` 越存越长(上限 50 条),
* 最后表现为「明明都在跟总开关,却报超过上限」。
* 返回新对象,不动传进来的那份(Vue 的依赖追踪靠引用变化)。
*/
export function withOverride(
overrides: Record<string, ScheduleOverride>,
instanceId: string,
value: ScheduleOverride,
): Record<string, ScheduleOverride> {
const next = { ...overrides }
if (value === 'inherit') delete next[instanceId]
else next[instanceId] = value
return next
}
/**
* 卡片上要列哪几块屏:**在线实例全列**,另外把「离线但身上挂着非 inherit 覆盖」的也列上。
*
* 后者不为 0 时,操作员在这张卡片上就再也改不动它了——现场是「那块平板今天没开,
* 但我先把它的强制关撤掉」,列表里没有它就等于这个设置只能靠改库去掉。
*/
export function overrideTargets(
instances: DisplayInstance[],
overrides: Record<string, ScheduleOverride>,
): DisplayInstance[] {
return instances.filter((instance) => instance.online || overrideOf(overrides, instance.id) !== 'inherit')
}
/**
* 列表里根本找不到的覆盖条数(实例早已从注册表消失)。
*
* 这些设置**照样原样提交**(那是别的代理的键,我们没有资格替它删),只是在卡片上说明一句,
* 免得操作员以为「列表里没有 = 已经清掉了」。
*/
export function hiddenOverrideCount(
instances: DisplayInstance[],
overrides: Record<string, ScheduleOverride>,
): number {
const known = new Set(instances.map((instance) => instance.id))
return Object.keys(overrides).filter((id) => overrideOf(overrides, id) !== 'inherit' && !known.has(id)).length
}
// ── 提示语 ──────────────────────────────────────────────────────────────────
/** 保存成功那句「已把 X 改成 Y」里的 Y。 */
export function scheduleSummary(config: ScheduleSidebarConfig): string {
const side = SCHEDULE_SIDE_OPTIONS.find((option) => option.value === config.side)?.label ?? config.side
return `共 ${config.items.length} 条 · 默认${config.visible ? '开启' : '关闭'} · ${side} ${config.widthPercent}%`
}
+181
View File
@@ -0,0 +1,181 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
/**
* 「赛事日程」卡片的接线。
*
* 页面级测试在仓库里跑不起来(没有 jsdom / @vue/test-utils),所以组件那半边退化成源码断言。
* 这里钉的四件事,每一件写错的后果都在现场:
* 1. 卡片必须挂在**公告卡之后**(左列从上到下的顺序就是现场的操作顺序);
* 2. 保存必须走 `adminApi.displayCommand` —— 走 `settingsApi.save` 是 admin-only,
* 会把「录入员及以上可改」这条需求当场变成必然 403(本页的实时赛况就是因为这个才置灰的);
* 3. 排序只有上移 / 下移两个按钮:**绝不能挂 pointerdown / mousedown / touchstart**
* (与 lan-qr-toggle 同一口径 —— 按下的那一刻重排会吞掉随后的 click);
* 4. 单屏覆盖的键是**实例 id**,不是屏号 / 下标 / 实例对象上的新字段
* (往 GET /api/display/instances 的结构里塞字段会被 deepEqual 拦住)。
*/
const PAGE = readFileSync(new URL('../pages/DisplayPage.vue', import.meta.url), 'utf8')
/** 卡片那一段模板:从卡片上方的说明注释起,到它自己的 `</section>` 止。 */
function cardBlock(): string {
const start = PAGE.indexOf('赛事日程侧边栏:手填日程 + 总开关 + 每块屏三态覆盖')
const end = PAGE.indexOf('</section>', start)
return start < 0 || end < 0 ? '' : PAGE.slice(start, end)
}
/** 卡片那一段脚本:从分区注释起,到下一位邻居(实例显示名)止。 */
function scriptBlock(): string {
const start = PAGE.indexOf('// ── 赛事日程侧边栏')
const end = PAGE.indexOf('function instanceLabel', start)
return start < 0 || end < 0 ? '' : PAGE.slice(start, end)
}
test('卡片挂在公告卡之后、实时赛况之前', () => {
const announce = PAGE.indexOf('<span class="mx-card-title">公告</span>')
const schedule = PAGE.indexOf('<span class="mx-card-title">赛事日程</span>')
const live = PAGE.indexOf('<span class="mx-card-title">实时赛况</span>')
assert.notEqual(announce, -1)
assert.notEqual(schedule, -1)
assert.ok(announce < schedule, '赛事日程卡片必须在公告卡之后')
assert.ok(schedule < live, '赛事日程卡片必须在实时赛况卡之前')
// 是一张独立卡片,不是塞进公告卡的角落里。
const block = cardBlock()
assert.notEqual(block, '', '找不到卡片模板')
assert.match(block, /<section class="mx-card">/)
})
/** 注释里提到这些名字(正是为了说明「别改成那样」)不算接线,统计前先剥掉。 */
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
}
test('保存走 adminApi.displayCommand,脚本里不出现 settingsApi.save', () => {
const script = scriptBlock()
assert.notEqual(script, '', '找不到卡片的脚本块')
assert.match(script, /await adminApi\.displayCommand\(scheduleCommand\(config\)\)/)
// 这是本卡片最容易写错的一处:PUT /api/settings 是 admin-only,录入员会吃 403。
// 注释里那张对照表不算数(它存在的意义就是提醒别改成那条路),剥掉注释再看。
assert.doesNotMatch(stripComments(script), /settingsApi/)
// 请求体由纯函数组装(整份配置 + targets: []),页面不自己拼 payload。
assert.match(script, /scheduleCommand/)
})
test('没有 pointerdown / mousedown / touchstart 监听(排序只用按钮)', () => {
// 与 admin/tests/lan-qr-toggle.test.ts 同一条护栏:按下的那一刻重排会吞掉 click。
assert.doesNotMatch(PAGE, /addEventListener\('(pointerdown|mousedown|mouseup|touchstart)'/)
const block = cardBlock()
// 卡片里也没有 `draggable` 这类拖拽痕迹。
assert.doesNotMatch(block, /draggable|dragstart|drop\.|@drop/)
})
test('条目表格:五列齐全,行内编辑绑在行上', () => {
const block = cardBlock()
for (const head of ['时间', '标题', '备注', '已完成', '操作']) {
assert.ok(block.includes(`>${head}</th>`) || block.includes(`>${head}<`) || block.includes(head), `表头缺少「${head}」`)
}
assert.match(block, /v-for="\(row, index\) in scheduleRows" :key="row\.key"/)
assert.match(block, /v-model="row\.time"/)
assert.match(block, /v-model="row\.title"/)
assert.match(block, /v-model="row\.note"/)
assert.match(block, /v-model="row\.done"/)
// 长度上限用与服务端同一批常量,不写死数字。
assert.match(block, /:maxlength="SCHEDULE_TIME_MAX"/)
assert.match(block, /:maxlength="SCHEDULE_TITLE_MAX"/)
assert.match(block, /:maxlength="SCHEDULE_NOTE_MAX"/)
})
test('操作列:上移 / 下移 / 删除都绑上了,边界行禁用', () => {
const block = cardBlock()
assert.match(block, /@click="moveRow\(index, -1\)"/)
assert.match(block, /@click="moveRow\(index, 1\)"/)
assert.match(block, /:disabled="index === 0 \|\| scheduleLocked"/)
assert.match(block, /:disabled="index === scheduleRows\.length - 1 \|\| scheduleLocked"/)
assert.match(block, /@click="removeScheduleRow\(row\.key\)"/)
assert.match(block, /@click="addScheduleRow"/)
// 行内按钮走的是纯函数,页面里没有第二套交换逻辑。
const script = scriptBlock()
assert.match(script, /scheduleRows\.value = moveScheduleRow\(scheduleRows\.value, index, delta\)/)
assert.match(script, /scheduleRows\.value = \[\.\.\.scheduleRows\.value, emptyScheduleRow\(takeScheduleKey\(\)\)\]/)
assert.match(script, /scheduleRows\.value\.filter\(\(row\) => row\.key !== key\)/)
})
test('总开关与备注开关用 mx-switch,且都绑在卡片状态上', () => {
const block = cardBlock()
assert.match(block, /<label class="mx-switch">\s*<input v-model="scheduleVisible" type="checkbox"/)
assert.match(block, /<input v-model="scheduleShowNote" type="checkbox"/)
// 已完成也是一样的开关(每行一个),所以卡片里至少三个。
assert.ok((block.match(/class="mx-switch/g) ?? []).length >= 3, '卡片里的开关数量不对')
// 禁用一律走同一个 gate(未登录 / 未连接 / 提交中)。
assert.match(block, /:disabled="scheduleLocked"/)
})
test('位置与宽度:选项来自常量表,宽度给 15–40 的边界', () => {
const block = cardBlock()
assert.match(block, /v-for="option in SCHEDULE_SIDE_OPTIONS"/)
assert.match(block, /v-model="scheduleSide"/)
assert.match(block, /v-model\.number="scheduleWidth"/)
assert.match(block, /:min="SCHEDULE_WIDTH_MIN"/)
assert.match(block, /:max="SCHEDULE_WIDTH_MAX"/)
// 卡片上要说清这是深色半透明面板(具体 CSS 在大屏端,管理端只标注形态)。
assert.match(block, /深色半透明/)
})
test('单屏三态覆盖:键是实例 id,三态下拉绑在覆盖状态上', () => {
const block = cardBlock()
assert.match(block, /v-for="instance in scheduleTargets" :key="instance\.id"/)
assert.match(block, /v-for="option in SCHEDULE_OVERRIDE_OPTIONS"/)
assert.match(block, /:value="overrideOf\(scheduleOverrides, instance\.id\)"/)
assert.match(block, /@change="pickScheduleOverride\(instance\.id, \$event\)"/)
// 标题用既有的显示名逻辑(别名 → 设备 → 实例 id),不自己再拼一套。
assert.match(block, /instanceDisplayName\(instance\)/)
assert.match(block, /instanceIdNote\(instance\)/)
// 键必须是实例 id:屏号(同一台机器可能没有屏号)与下标都不行。
assert.doesNotMatch(block, /scheduleOverrides\[instance\.screen\]/)
assert.doesNotMatch(block, /scheduleOverrides\[index\]/)
})
test('权限:整卡只读时给中文原因,保存按钮也按同一句话置灰', () => {
const block = cardBlock()
assert.match(block, /<p v-if="scheduleGateReason !== null" class="notice">\{\{ scheduleGateReason \}\}<\/p>/)
assert.match(block, /:title="scheduleGateReason \?\? '保存后大屏立刻生效'"/)
assert.match(block, /:disabled="scheduleLocked"/)
const script = scriptBlock()
// 程序化触发也要有一句明确的权限回答,而不是去等 403。
assert.match(script, /toast\.error\('当前不可保存赛事日程'/)
// 只读判定跟 canWrite 走(录入员及以上),不再额外要管理员。
assert.match(script, /scheduleGateReason = computed\(\(\) =>\s*canWrite\.value \? null/)
})
test('本地校验:不合规就标红并不提交,红字来自纯函数', () => {
const script = scriptBlock()
assert.match(script, /const errors = validateSchedule\(scheduleRows\.value, scheduleWidth\.value\)/)
assert.match(script, /if \(hasScheduleErrors\(errors\)\) \{/)
// 拦下之后必须先 return —— 少这一句就是「标了红却照样提交」。
const guard = /if \(hasScheduleErrors\(errors\)\) \{[\s\S]*?\n \}/.exec(script)?.[0] ?? ''
assert.match(guard, /toast\.error\('赛事日程校验未通过'/)
assert.match(guard, /return/)
const block = cardBlock()
assert.match(block, /scheduleErrors\.rows\[row\.key\]\?\.title/)
assert.match(block, /v-for="message in scheduleFormErrors"/)
})
test('保存成功 / 失败:成功那句是「已把 X 改成 Y」,失败把服务端原文交给 toast', () => {
const script = scriptBlock()
assert.match(script, /toast\.success\(\s*`已把赛事日程改成「\$\{scheduleSummary\(config\)\}」`/)
assert.match(script, /toast\.error\('保存赛事日程失败', caught instanceof Error \? caught\.message : null\)/)
// 示例数据模式下没有真服务端,不能谎报「已保存」。
assert.match(script, /toast\.warning\('示例数据模式未真正保存'/)
})
test('数据来源:读设置时把服务端那份日程同步进卡片,读不到时用默认值', () => {
const apply = /function applyScheduleConfig[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
assert.notEqual(apply, '', '找不到 applyScheduleConfig')
assert.match(apply, /normalizeScheduleConfig\(raw\)/)
assert.match(apply, /rowsFromSchedule\(config, \(\) => takeScheduleKey\(\)\)/)
assert.match(apply, /scheduleWidth\.value = config\.widthPercent/)
assert.match(apply, /scheduleOverrides\.value = \{ \.\.\.\(config\.instanceOverrides \?\? \{\}\) \}/)
// 挂载时先用已有的那份设置垫上,refreshSettings 随后核对。
assert.match(PAGE, /applyScheduleConfig\(data\.settings\?\.display\?\.schedule \?\? null\)/)
assert.match(PAGE, /applyScheduleConfig\(settings\.display\?\.schedule \?\? null\)/)
})
+358
View File
@@ -0,0 +1,358 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { ScheduleSidebarConfig } from '@/shared'
import type { DisplayInstance } from '@/shared/api'
import {
SCHEDULE_MAX_ITEMS,
SCHEDULE_NOTE_MAX,
SCHEDULE_TIME_MAX,
SCHEDULE_TITLE_MAX,
SCHEDULE_WIDTH_ERROR,
SCHEDULE_WIDTH_MAX,
SCHEDULE_WIDTH_MIN,
charCount,
clampWidthPercent,
defaultScheduleConfig,
emptyScheduleErrors,
emptyScheduleRow,
hasScheduleErrors,
hiddenOverrideCount,
itemFromRow,
moveScheduleRow,
normalizeScheduleConfig,
overrideOf,
overrideTargets,
rowsFromSchedule,
scheduleCommand,
scheduleConfigFrom,
scheduleSummary,
validateSchedule,
withOverride,
type ScheduleRow,
} from '../pages/scheduleEditor'
/**
* 「赛事日程」卡片的纯逻辑。
*
* 组件跑不进测试(仓库里没有 jsdom),所以这一层是整个卡片的唯一可测面:默认值、
* 本地校验、上下移边界、三态覆盖的读写、请求体形状。这几件事写错的后果都在现场才看得见
* —— 最典型的是「切回跟随总开关时留了一个 `inherit` 垃圾键」,它会让 `instanceOverrides`
* 一次一次攒满 50 条上限,最后表现成「明明什么都没改,却报超过上限」。
*/
function row(partial: Partial<ScheduleRow> = {}): ScheduleRow {
return { key: 1, time: '', title: '开幕式', note: '', done: false, ...partial }
}
function config(partial: Partial<ScheduleSidebarConfig> = {}): ScheduleSidebarConfig {
return { ...defaultScheduleConfig(), ...partial }
}
// ── 默认值 ──────────────────────────────────────────────────────────────────
test('默认配置:空列表、关闭、右侧 22%、备注上屏', () => {
assert.deepEqual(defaultScheduleConfig(), {
items: [],
visible: false,
side: 'right',
widthPercent: 22,
showNote: true,
})
// 每次都是新对象:页面把它当初始值存进 ref,共享同一个引用会让两处改动互相串。
assert.notEqual(defaultScheduleConfig(), defaultScheduleConfig())
// 默认不带单屏覆盖这个键:`inherit` 就是「没有这条设置」,不该在默认值里预先摆一堆空键。
assert.equal('instanceOverrides' in defaultScheduleConfig(), false)
})
test('没配置(老服务端 / 坏 JSON)时收下默认值,不抛异常', () => {
assert.deepEqual(normalizeScheduleConfig(null), defaultScheduleConfig())
assert.deepEqual(normalizeScheduleConfig(undefined), defaultScheduleConfig())
// 服务端把 schedule 写成了字符串 / 数字这类坏形状。
assert.deepEqual(normalizeScheduleConfig('nope' as unknown as ScheduleSidebarConfig), defaultScheduleConfig())
})
test('解析服务端配置:坏字段逐条兜底,不整份丢掉', () => {
const parsed = normalizeScheduleConfig({
items: [
{ time: '08:30', title: '开幕式', done: true },
{ time: 5, title: '男子 100 米决赛', note: '检录 08:00' },
// 没有标题的条目在大屏上就是一条空白线:整条不要。
{ time: '09:00' },
'nope',
],
visible: true,
side: 'left',
widthPercent: 33,
showNote: false,
instanceOverrides: { 'display-a1b2c3d4': 'off', 'display-dead': 'inherit', 'display-x': 'nope' },
} as unknown as ScheduleSidebarConfig)
assert.equal(parsed.visible, true)
assert.equal(parsed.side, 'left')
assert.equal(parsed.widthPercent, 33)
assert.equal(parsed.showNote, false)
assert.deepEqual(parsed.items, [
{ time: '08:30', title: '开幕式', done: true },
// time 不是字符串 → 空串;done 缺省 → false;note 照收。
{ time: '', title: '男子 100 米决赛', done: false, note: '检录 08:00' },
])
// `inherit` 与不认识的值都不留键:它们等于「没有这条设置」。
assert.deepEqual(parsed.instanceOverrides, { 'display-a1b2c3d4': 'off' })
})
test('解析时越界宽度夹到 15–40,方向未知退回右侧', () => {
assert.equal(normalizeScheduleConfig(config({ widthPercent: 4 })).widthPercent, SCHEDULE_WIDTH_MIN)
assert.equal(normalizeScheduleConfig(config({ widthPercent: 90 })).widthPercent, SCHEDULE_WIDTH_MAX)
assert.equal(normalizeScheduleConfig(config({ widthPercent: Number.NaN })).widthPercent, 22)
assert.equal(normalizeScheduleConfig(config({ side: 'top' as 'left' })).side, 'right')
// 条目超过上限只留前 40 条(服务端同样只认 40 条)。
const many = Array.from({ length: 45 }, (_, index) => ({ time: '', title: `第 ${index + 1} 项`, done: false }))
assert.equal(normalizeScheduleConfig(config({ items: many })).items.length, SCHEDULE_MAX_ITEMS)
assert.equal(clampWidthPercent('22'), 22)
})
// ── 行 ──────────────────────────────────────────────────────────────────────
test('配置 → 行:key 由页面给,note / done 缺省归一', () => {
const rows = rowsFromSchedule(config({ items: [{ time: '上午', title: '开幕式' }] }))
assert.deepEqual(rows, [{ key: 1, time: '上午', title: '开幕式', note: '', done: false }])
// 页面持有一个单调递增的计数器,保证删一行再加一行不撞 key。
assert.deepEqual(rowsFromSchedule(config({ items: [{ time: '', title: 'A' }, { time: '', title: 'B' }] }), (i) => 100 + i), [
{ key: 100, time: '', title: 'A', note: '', done: false },
{ key: 101, time: '', title: 'B', note: '', done: false },
])
assert.deepEqual(rowsFromSchedule(defaultScheduleConfig()), [])
})
test('新增的空行:默认未完成,四个字段都空', () => {
assert.deepEqual(emptyScheduleRow(7), { key: 7, time: '', title: '', note: '', done: false })
})
test('上移 / 下移:中间两行交换,且不改动原数组', () => {
const rows = [row({ key: 1, title: 'A' }), row({ key: 2, title: 'B' }), row({ key: 3, title: 'C' })]
const moved = moveScheduleRow(rows, 1, -1)
assert.deepEqual(moved.map((item) => item.title), ['B', 'A', 'C'])
// 原地改动会让 Vue 的依赖追踪漏掉这次变更,所以必须返回新数组。
assert.notEqual(moved, rows)
assert.deepEqual(rows.map((item) => item.title), ['A', 'B', 'C'])
const down = moveScheduleRow(rows, 1, 1)
assert.deepEqual(down.map((item) => item.title), ['A', 'C', 'B'])
assert.deepEqual(rows.map((item) => item.title), ['A', 'B', 'C'])
})
test('上移 / 下移的边界:第一条上移、最后一条下移原样返回同一个引用', () => {
const rows = [row({ key: 1, title: 'A' }), row({ key: 2, title: 'B' })]
// 同引用 = 页面写回 ref 时不会触发任何重渲染,「点了没反应」才是正确表现。
assert.equal(moveScheduleRow(rows, 0, -1), rows)
assert.equal(moveScheduleRow(rows, 1, 1), rows)
// 越界下标(程序化调用)也不许造出一个新数组。
assert.equal(moveScheduleRow(rows, -1, 1), rows)
assert.equal(moveScheduleRow(rows, 9, -1), rows)
assert.equal(moveScheduleRow([], 0, 1).length, 0)
})
// ── 本地校验 ────────────────────────────────────────────────────────────────
test('长度按字符算:中文与 emoji 各算一个(与服务端的 rune 计数一致)', () => {
assert.equal(charCount('男子 100 米'), 8)
// 带连接符的 emoji 由 4 个码点组成(人 + ZWJ + 性别符号 + 变体选择符),服务端按 rune 也数 4;
// 用 `.length`(UTF-16 码元)会数成 5,本地就会把服务端明明收得下的标题标红。
assert.equal(charCount('🏃‍♂️'), 4)
assert.equal('🏃‍♂️'.length, 5)
// 40 个中文字刚好合格,41 个超标 —— 用 .length 的话中文不会差,但 emoji 会误报。
const title = '决'.repeat(SCHEDULE_TITLE_MAX)
assert.equal(validateSchedule([row({ title })], 22).rows[1], undefined)
assert.equal(
validateSchedule([row({ title: `${title}决` })], 22).rows[1]?.title,
`标题最多 ${SCHEDULE_TITLE_MAX} 字`,
)
})
test('校验:合法的行与宽度通过,没有任何红字', () => {
const rows = [
row({ key: 1, time: '08:30', title: '开幕式', note: '检录 08:00' }),
row({ key: 2, time: '上午', title: '男子 100 米决赛', done: true }),
// 时间可以先留空回头再填(大屏上就是一格空白时间)。
row({ key: 3, time: '', title: '待定项目' }),
]
const errors = validateSchedule(rows, 22)
assert.deepEqual(errors, emptyScheduleErrors())
assert.equal(hasScheduleErrors(errors), false)
// 边界值也算合法。
assert.equal(hasScheduleErrors(validateSchedule(rows, SCHEDULE_WIDTH_MIN)), false)
assert.equal(hasScheduleErrors(validateSchedule(rows, SCHEDULE_WIDTH_MAX)), false)
})
test('校验:条数超过 40 时给与行无关的红字', () => {
const rows = Array.from({ length: SCHEDULE_MAX_ITEMS + 1 }, (_, index) => row({ key: index + 1, title: `第 ${index + 1} 项` }))
const errors = validateSchedule(rows, 22)
assert.deepEqual(errors.form, [`最多 ${SCHEDULE_MAX_ITEMS} 条日程(当前 ${SCHEDULE_MAX_ITEMS + 1} 条)`])
assert.equal(hasScheduleErrors(errors), true)
// 正好 40 条不报。
assert.deepEqual(validateSchedule(rows.slice(0, SCHEDULE_MAX_ITEMS), 22).form, [])
})
test('校验:标题必填、各字段超长各给各的行内红字', () => {
const rows = [
row({ key: 1, title: ' ' }),
row({ key: 2, title: 'A', time: 'x'.repeat(SCHEDULE_TIME_MAX + 1) }),
row({ key: 3, title: 'B', note: 'x'.repeat(SCHEDULE_NOTE_MAX + 1) }),
]
const errors = validateSchedule(rows, 22)
assert.equal(errors.rows[1]?.title, '标题必填')
assert.equal(errors.rows[2]?.time, `时间最多 ${SCHEDULE_TIME_MAX} 字`)
assert.equal(errors.rows[3]?.note, `备注最多 ${SCHEDULE_NOTE_MAX} 字`)
// 同一行的另一格不会被连坐标红。
assert.equal(errors.rows[2]?.title, undefined)
assert.deepEqual(errors.form, [])
})
test('校验:宽度非数字 / 越界都是同一句红字(空输入框也算)', () => {
for (const width of ['', 14, SCHEDULE_WIDTH_MAX + 1, Number.NaN, 'abc']) {
const errors = validateSchedule([row()], width)
assert.deepEqual(errors.form, [SCHEDULE_WIDTH_ERROR], `宽度 ${String(width)} 应当被拦下`)
}
assert.deepEqual(validateSchedule([row()], 15).form, [])
assert.deepEqual(validateSchedule([row()], 40).form, [])
})
test('空列表 + 合法宽度是合法配置(等于「大屏上没有侧边栏内容」)', () => {
assert.equal(hasScheduleErrors(validateSchedule([], 22)), false)
})
// ── 三态覆盖 ────────────────────────────────────────────────────────────────
test('三态:没有键就是「跟随总开关」', () => {
assert.equal(overrideOf({}, 'display-a'), 'inherit')
assert.equal(overrideOf(undefined, 'display-a'), 'inherit')
// 坏值(手工改库 / 老版本写进去的)也当跟随,不能把大屏切成一个不认识的状态。
assert.equal(overrideOf({ 'display-a': 'maybe' as 'on' }, 'display-a'), 'inherit')
})
test('三态:切 on / off 写进键,切回 inherit 时真删掉那个键', () => {
const base: Record<string, 'inherit' | 'on' | 'off'> = { 'display-a': 'off' }
const on = withOverride(base, 'display-a', 'on')
assert.deepEqual(on, { 'display-a': 'on' })
// 新对象:原地改会让 Vue 的依赖追踪漏掉这次变更。
assert.notEqual(on, base)
assert.deepEqual(base, { 'display-a': 'off' })
const backToInherit = withOverride(on, 'display-a', 'inherit')
assert.deepEqual(backToInherit, {})
// 关键:键必须**消失**,不能留一个 `'inherit'` 的垃圾(攒满 50 条上限就会开始被服务端拒绝)。
assert.equal('display-a' in backToInherit, false)
// 字符串化之后也看不到那个键:服务端与快照比对的是 JSON。
assert.equal(JSON.stringify(backToInherit), '{}')
})
test('三态:多块屏互不影响', () => {
let overrides: Record<string, 'inherit' | 'on' | 'off'> = {}
overrides = withOverride(overrides, 'display-a', 'on')
overrides = withOverride(overrides, 'display-b', 'off')
overrides = withOverride(overrides, 'display-a', 'inherit')
assert.deepEqual(overrides, { 'display-b': 'off' })
})
const ONLINE: DisplayInstance = {
id: 'display-a',
online: true,
page: 'standings',
lastHeartbeat: '2026-09-17T10:00:00Z',
connectedAt: '2026-09-17T09:00:00Z',
remoteAddr: '192.168.1.23:52341',
ackVersion: 7,
slow: false,
screen: '',
}
const OFFLINE_PLAIN: DisplayInstance = { ...ONLINE, id: 'display-b', online: false }
const OFFLINE_WITH_OVERRIDE: DisplayInstance = { ...ONLINE, id: 'display-c', online: false }
test('要列哪几块屏:在线全列,离线但挂着覆盖的也列(否则那个设置再也改不回来)', () => {
const overrides = { 'display-c': 'off' as const, 'display-gone': 'on' as const }
const targets = overrideTargets([ONLINE, OFFLINE_PLAIN, OFFLINE_WITH_OVERRIDE], overrides)
assert.deepEqual(targets.map((item) => item.id), ['display-a', 'display-c'])
assert.equal(hiddenOverrideCount([ONLINE, OFFLINE_PLAIN, OFFLINE_WITH_OVERRIDE], overrides), 1)
assert.equal(hiddenOverrideCount([ONLINE], {}), 0)
})
// ── 组装与请求体 ────────────────────────────────────────────────────────────
test('行 → 条目:首尾空白去掉,空备注整键不出现', () => {
assert.deepEqual(itemFromRow(row({ time: ' 08:30 ', title: ' 开幕式 ', note: ' ', done: true })), {
time: '08:30',
title: '开幕式',
done: true,
})
assert.deepEqual(itemFromRow(row({ note: ' 检录 08:00 ' })), {
time: '',
title: '开幕式',
note: '检录 08:00',
done: false,
})
})
test('组装整份配置:顺序照行顺序,不做任何排序', () => {
const rows = [row({ key: 1, time: '待定', title: '颁奖' }), row({ key: 2, time: '08:30', title: '开幕式' })]
const config = scheduleConfigFrom(rows, {
visible: true,
side: 'left',
widthPercent: 30,
showNote: false,
instanceOverrides: { 'display-a': 'on' },
})
assert.deepEqual(config, {
items: [
{ time: '待定', title: '颁奖', done: false },
{ time: '08:30', title: '开幕式', done: false },
],
visible: true,
side: 'left',
widthPercent: 30,
showNote: false,
instanceOverrides: { 'display-a': 'on' },
})
// 手填时间不参与排序:调换两行的顺序就是调换数组顺序,没有任何隐式重排。
const swapped = scheduleConfigFrom([rows[1]!, rows[0]!], {
visible: true,
side: 'left',
widthPercent: 30,
showNote: false,
instanceOverrides: {},
})
assert.deepEqual(swapped.items.map((item) => item.title), ['开幕式', '颁奖'])
})
test('请求体:schedule 动作 + 整份配置 + targets 为空(广播给所有大屏)', () => {
const config = scheduleConfigFrom([row({ time: '08:30', title: '开幕式' })], {
visible: true,
side: 'right',
widthPercent: 22,
showNote: true,
instanceOverrides: { 'display-a': 'off' },
})
const command = scheduleCommand(config)
assert.equal(command.action, 'schedule')
assert.deepEqual(command.targets, [])
// payload 里带的就是**整份**配置(服务端不做局部合并),且是同一个对象、不复制。
assert.equal(command.payload.schedule, config)
assert.deepEqual(JSON.parse(JSON.stringify(command)), {
action: 'schedule',
payload: {
schedule: {
items: [{ time: '08:30', title: '开幕式', done: false }],
visible: true,
side: 'right',
widthPercent: 22,
showNote: true,
instanceOverrides: { 'display-a': 'off' },
},
},
targets: [],
})
})
test('保存成功那句「已把 X 改成 Y」:条数、默认开关、位置与宽度', () => {
assert.equal(scheduleSummary(config({ items: [{ time: '', title: 'A' }], visible: true, side: 'left', widthPercent: 30 })), '共 1 条 · 默认开启 · 左侧 30%')
assert.equal(scheduleSummary(defaultScheduleConfig()), '共 0 条 · 默认关闭 · 右侧 22%')
})
+4
View File
@@ -8,6 +8,7 @@ import { useScoreboard } from './useScoreboard'
import { useStageScale } from './useStageScale'
import { WINDOWED_TITLEBAR_HEIGHT, isWindowed } from './windowed'
import DetailOverlay from './components/DetailOverlay.vue'
import ScheduleSidebar from './components/ScheduleSidebar.vue'
import TickerBar from './components/TickerBar.vue'
import TopBar from './components/TopBar.vue'
import WindowedTitleBar from './components/WindowedTitleBar.vue'
@@ -113,6 +114,9 @@ onUnmounted(() => {
</div>
<DetailOverlay />
<!-- 日程侧边栏叠在**所有页面与明细覆盖层之上**,但它只读 detail(明细开着就先收起),
不参与明细状态机:goto 关明细、明细停轮播都跟它无关。 -->
<ScheduleSidebar />
</div>
</div>
</div>
@@ -0,0 +1,413 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useScoreboard } from '../useScoreboard'
import { instanceIdStorage, resolveDisplayIdentity } from '../instanceId'
import { textEm } from '../fitFont'
import {
SIDEBAR_AXIS_COLUMN_PX,
SIDEBAR_AXIS_GAP_PX,
SIDEBAR_PADDING_X_PX,
SIDEBAR_PADDING_Y_PX,
currentScheduleIndex,
overallProgress,
resolveSidebarConfig,
scheduleLayout,
scheduleNodeState,
sidebarGeometry,
sidebarSuppressed,
truncateScheduleText,
visibleScheduleWindow,
} from '../schedulePanel'
import type { ScheduleNodeState } from '../schedulePanel'
/**
* 赛事日程侧边栏:叠在**任意当前页**之上的半透明毛玻璃面板。
*
* 三条边界(写在这里免得后来者顺手改坏):
* 1. **独立于 `detail` 状态机**:只读 detail 决定「要不要先收起来」,绝不开关明细、
* 也绝不碰轮播——管理端发 `goto` 会关明细、明细覆盖会停轮播,而侧边栏两件都不做。
* 2. **关着的时候一件 DOM 都不留**:`v-if` 在根上,关闭时连一个空 div 都没有,
* 大屏逐字节等同于没有这个功能(入场动效因此走 CSS animation 而不是 <Transition>,
* 后者会留下注释占位节点)。
* 3. **尺寸全是舞台设计坐标**:面板挂在 `.stage` 里、跟着 `transform: scale()` 一起缩放,
* 所以字号 / 间距都不能按视口算(见 schedulePanel.ts 顶上那段「双重缩放」)。
*/
const { schedule, detail, page, announcement, options } = useScoreboard()
/**
* 本机实例 id:与大屏上报给服务端的**是同一把钥匙**(Console 窗口的 `instance`、
* 只带屏号的 `screen`、局域网浏览器本机稳定 id 都在 displayKey 里汇合)。
* 拿不到(localStorage 不可用且 URL 没带)就是空串 —— 此时单屏覆盖一律按「跟随总开关」处理。
*/
const instanceId = computed(
() =>
resolveDisplayIdentity(
{ instance: options.value.instance, screen: options.value.screen },
instanceIdStorage(),
).displayKey ?? '',
)
const resolved = computed(() => resolveSidebarConfig(schedule.value, instanceId.value))
const side = computed(() => resolved.value.side)
/** 全屏公告 = 公告状态机在跑、且页面就在公告页(计时结束退回普通页后它自己就解除了)。 */
const announceFullscreen = computed(() => announcement.value !== null && page.value === 'announcement')
/**
* 明细覆盖层 / 全屏公告期间自动收起(定稿口径 6)。
* 只影响渲染,配置本身一个字节都不动:明细关掉后原样回来。
*/
const visible = computed(
() => resolved.value.enabled && !sidebarSuppressed(detail.value !== null, announceFullscreen.value),
)
const items = computed(() => schedule.value?.items ?? [])
const progress = computed(() => overallProgress(items.value))
const currentIndex = computed(() => currentScheduleIndex(items.value))
const geometry = computed(() => sidebarGeometry(resolved.value.widthPercent))
/**
* 排版决策的输入取**整份列表**(不是当前窗口):窗口随当前项推移而移动,
* 若按窗口算,窗口一挪字号就跳一下;按整列表算则全程稳定,代价只是一条特别长的条目
* 会让全屏字号小一档——比「翻到哪条字就变多大」强。
*/
const layout = computed(() =>
scheduleLayout({
itemCount: items.value.length,
availablePx: geometry.value.availablePx,
columnPx: geometry.value.columnPx,
longestTitleEm: items.value.reduce((max, item) => Math.max(max, textEm(item.title)), 0),
longestNoteEm: items.value.reduce((max, item) => Math.max(max, textEm(item.note ?? '')), 0),
hasTime: items.value.some((item) => item.time !== ''),
showNote: resolved.value.showNote,
}),
)
const shownWindow = computed(() =>
visibleScheduleWindow(items.value, currentIndex.value, layout.value.capacity),
)
/** 一条目各行能用的显示宽度(em):标题按它的行数预算、时间与备注各占一行 / 两行。 */
const titleBudgetEm = computed(
() => (geometry.value.columnPx * layout.value.titleLines) / layout.value.fontPx,
)
const noteBudgetEm = computed(() =>
layout.value.noteLines > 0
? (geometry.value.columnPx * layout.value.noteLines) / layout.value.noteFontPx
: 0,
)
const timeBudgetEm = computed(() => geometry.value.columnPx / layout.value.noteFontPx)
interface SidebarRow {
key: string
state: ScheduleNodeState
time: string
title: string
note: string
}
const rows = computed<SidebarRow[]>(() => {
const list = items.value
const win = shownWindow.value
return list.slice(win.start, win.end).map((item, offset) => {
const index = win.start + offset
return {
// 时间 / 标题可以重复(手填数据),下标才是唯一键。
key: `${index}`,
state: scheduleNodeState(item, index, currentIndex.value),
time: truncateScheduleText(item.time, timeBudgetEm.value),
title: truncateScheduleText(item.title, titleBudgetEm.value),
note: truncateScheduleText(item.note ?? '', noteBudgetEm.value),
}
})
})
const barWidth = computed(() => {
const { done, total } = progress.value
return total === 0 ? '0%' : `${Math.round((done / total) * 100)}%`
})
/**
* 面板几何与字号全部走 CSS 变量,量都来自 schedulePanel.ts —— 单一来源,
* 改一个常量不会出现「算的时候按 22%、画的时候按 24%」这种对不上的情况。
*/
const panelStyle = computed<Record<string, string>>(() => ({
'--sb-pad-x': `${SIDEBAR_PADDING_X_PX}px`,
'--sb-pad-y': `${SIDEBAR_PADDING_Y_PX}px`,
'--sb-axis': `${SIDEBAR_AXIS_COLUMN_PX}px`,
'--sb-gap': `${SIDEBAR_AXIS_GAP_PX}px`,
'--sb-font': `${layout.value.fontPx}px`,
'--sb-note-font': `${layout.value.noteFontPx}px`,
'--sb-row-h': `${layout.value.rowHeightPx}px`,
'--sb-row-gap': `${layout.value.rowGapPx}px`,
width: `${geometry.value.panelPx}px`,
[side.value]: '0',
}))
</script>
<template>
<div v-if="visible" class="schedule-layer">
<aside
class="panel"
:class="side"
:style="panelStyle"
aria-label="赛事日程"
data-schedule-sidebar
:data-side="side"
:data-font-px="layout.fontPx"
:data-capacity="layout.capacity"
:data-visible-rows="rows.length"
:data-available-px="geometry.availablePx"
>
<header class="head">
<div class="head-row">
<span class="brand">赛事日程</span>
<span class="readout">已完成 {{ progress.done }} / 共 {{ progress.total }}</span>
</div>
<div class="bar"><i :style="{ width: barWidth }" /></div>
</header>
<p v-if="shownWindow.hiddenBefore > 0" class="more">上方还有 {{ shownWindow.hiddenBefore }} 项</p>
<ol class="list">
<li v-for="row in rows" :key="row.key" class="row" :class="row.state">
<span class="axis"><i class="node" /></span>
<div class="body">
<p v-if="row.time" class="time">{{ row.time }}</p>
<p class="title">{{ row.title }}</p>
<p v-if="row.note" class="note">{{ row.note }}</p>
</div>
</li>
</ol>
<p v-if="shownWindow.hiddenAfter > 0" class="more">下方还有 {{ shownWindow.hiddenAfter }} 项</p>
<p v-if="rows.length === 0" class="empty">暂无日程安排</p>
</aside>
</div>
</template>
<style scoped>
/* 覆盖层只占位不拦事件:大屏本来就没有交互,pointer-events:none 保证它永远不会挡住当前页。 */
.schedule-layer {
position: absolute;
inset: 0;
z-index: 50;
pointer-events: none;
overflow: hidden;
}
.panel {
position: absolute;
top: 0;
bottom: 0;
padding: var(--sb-pad-y) var(--sb-pad-x);
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--text);
/* 定稿口径 3B:深色半透明 + 毛玻璃。底子足够暗,白字对比度才守得住。 */
background: linear-gradient(180deg, rgba(6, 16, 32, 0.72), rgba(4, 11, 24, 0.82));
backdrop-filter: blur(18px) saturate(1.15);
-webkit-backdrop-filter: blur(18px) saturate(1.15);
animation: sb-in 0.4s ease-out both;
}
.panel.right {
border-left: 3px solid var(--primary);
--sb-enter-x: 18px;
}
.panel.left {
border-right: 3px solid var(--primary);
--sb-enter-x: -18px;
}
/*
* 毛玻璃退化:`backdrop-filter` 不被支持(老浏览器 / 被禁用 / 被省电模式关掉)时,
* 半透明底会把身后的榜单穿出来,白字就跟成绩叠在一起看不清。
* 这里换成几乎不透明的深底,观感与「有毛玻璃」的暗色更接近,仍然是一块干净的深色面板。
*/
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.panel {
background: linear-gradient(180deg, rgba(4, 11, 24, 0.97), rgba(3, 8, 18, 0.98));
}
}
.head {
flex: none;
padding-bottom: 14px;
border-bottom: 2px solid var(--primary-line);
}
/* 表头一行放不下时(窄屏 15%)读数自动折到第二行:两个都 nowrap,
宁可多占一行也不把「赛事日程」拆成「赛事日 / 程」。 */
.head-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 4px 12px;
}
.brand {
font-size: 30px;
letter-spacing: 4px;
color: var(--text);
white-space: nowrap;
}
.readout {
font-size: 22px;
letter-spacing: 1px;
color: var(--primary-text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.bar {
margin-top: 14px;
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
border-radius: 3px;
background: var(--primary);
transition: width 0.4s ease;
}
.list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--sb-row-gap);
margin: 18px 0;
overflow: hidden;
list-style: none;
}
/* 条目等分整个列表高度:时间轴因此是一条通到底的连续线,也不会在面板底部留一片空。
等分的高度一定 ≥ 算出来的 --sb-row-h(scheduleLayout 保证总高不超过可用高度),
所以文字永远排得下。 */
.row {
flex: 1 1 0;
display: grid;
grid-template-columns: var(--sb-axis) 1fr;
column-gap: var(--sb-gap);
min-height: var(--sb-row-h);
/* 行本身**不能** overflow:hidden:轴段要靠负的行距外扩到上下两行之间,
裁掉它时间轴就断成一节一节了。文本溢出由 .body 那一层兜住。 */
}
.body {
min-width: 0;
overflow: hidden;
overflow-wrap: anywhere;
}
.time,
.note {
font-size: var(--sb-note-font);
line-height: 1.3;
color: var(--text-dim);
}
.time {
color: var(--primary-text);
font-variant-numeric: tabular-nums;
}
.title {
font-size: var(--sb-font);
line-height: 1.25;
color: var(--text);
}
/* 竖向轴:每一行画自己那一段,段与段之间用负的行距接上,视觉上是一条连续的线。 */
.axis {
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
}
.axis::before {
content: '';
position: absolute;
left: 50%;
top: calc(-1 * var(--sb-row-gap));
bottom: calc(-1 * var(--sb-row-gap));
width: 3px;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.14);
}
.node {
position: relative;
width: 12px;
height: 12px;
/* 节点对齐这一条**第一行文字**的中线(时间那行的半高减半个节点),而不是行高的正中:
行是等分拉高的,居中会让圆点漂到文字块下面去。 */
margin-top: calc((var(--sb-note-font) * 1.3 - 12px) / 2);
border-radius: 50%;
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.3);
}
/* 已完成(定稿口径 4A):淡化保留,看得出走过哪些。 */
.row.done .axis::before {
background: var(--primary-line);
}
.row.done .node {
width: 12px;
height: 12px;
border: none;
background: var(--primary);
opacity: 0.55;
}
/* 已完成的文字一起淡化(定稿口径 4A):看得出走过哪些,又不跟当前项抢注意力。 */
.row.done .title,
.row.done .time,
.row.done .note {
opacity: 0.62;
}
/* 当前项:放大 + 光晕,是全屏唯一「跳出来」的节点。 */
.row.current .axis::before {
background: linear-gradient(180deg, var(--primary) 0%, var(--primary) 50%, rgba(255, 255, 255, 0.14) 50%);
}
.row.current .node {
width: 20px;
height: 20px;
margin-top: calc((var(--sb-note-font) * 1.3 - 20px) / 2);
border: none;
background: var(--primary);
box-shadow:
0 0 0 6px var(--primary-flash),
0 0 18px var(--primary);
}
.row.current .title {
font-weight: 700;
}
.more,
.empty {
flex: none;
font-size: 22px;
letter-spacing: 2px;
color: var(--text-dim);
text-align: center;
}
.empty {
margin: auto 0;
}
/* 关掉动效(定稿口径 8):入场不做位移与淡入,直接出现。 */
@media (prefers-reduced-motion: reduce) {
.panel {
animation: none;
}
.bar i {
transition: none;
}
}
@keyframes sb-in {
from {
opacity: 0;
transform: translateX(var(--sb-enter-x, 18px));
}
to {
opacity: 1;
transform: none;
}
}
</style>
+94
View File
@@ -24,6 +24,9 @@ import type {
ResultMode,
ResultStatus,
ResultType,
ScheduleItem,
ScheduleOverride,
ScheduleSidebarConfig,
ScoreChannel,
SnapshotMessage,
StandingRow,
@@ -31,6 +34,7 @@ import type {
TeamBreakdown,
} from '@/shared'
import type { LiveEventSource } from './live'
import { MAX_SCHEDULE_ITEMS, clampSidebarWidthPercent } from './schedulePanel'
/**
* `display.rotateSeconds` 缺配置时的兜底秒数。
@@ -464,6 +468,92 @@ export function parseLiveEventIds(value: unknown): number[] {
return ids
}
/** 服务端写库前的字段上限(按**字符**),大屏这边原样夹一遍,坏数据不上屏。 */
const SCHEDULE_TIME_MAX_CHARS = 20
const SCHEDULE_TITLE_MAX_CHARS = 40
const SCHEDULE_NOTE_MAX_CHARS = 60
/** 单屏覆盖表的条目数上限与键长度上限,与服务端一致。 */
const SCHEDULE_OVERRIDE_MAX = 50
const SCHEDULE_OVERRIDE_KEY_MAX_CHARS = 120
const SCHEDULE_SIDES = ['left', 'right'] as const satisfies readonly ScheduleSidebarConfig['side'][]
/** 控制字符:手填日程常常是从聊天记录里粘来的,换行 / 制表符会撑破单行排版。 */
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g
/** 清洗 + 夹长:控制字符(手填文本可能是从别处粘来的)一律去掉,首尾空白去掉,超长按服务端口径截断。 */
function scheduleText(value: unknown, maxChars: number): string {
const raw = str(value).replace(CONTROL_CHARS, '').trim()
const chars = Array.from(raw)
if (chars.length <= maxChars) return raw
return `${chars.slice(0, maxChars - 1).join('')}…`
}
/**
* 赛事日程侧边栏的容错解析。
*
* 口径(大屏无人值守:一条脏数据不能把整页渲染炸掉):
* - `schedule` 这个键**不存在**(老服务端 / 旧缓存)或**形状坏掉**(不是对象)→ 返回 null = 没有日程;
* - 对象内部逐字段兜底:坏条目丢弃、越界值按服务端口径夹一下(宽度 15–40、条目 ≤ 40 条);
* - 时间不做格式校验(定稿口径:`08:30` / `上午` / `待定` 都是合法手填值)。
*
* 允许传入 JSON **字符串**:settings 表里存的是文本,万一哪条路径把没解析的原始值递下来,
* 这里也要能读(与 `display.announce_text` 那类扁平键同一个理由)。
*/
export function parseScheduleConfig(value: unknown): ScheduleSidebarConfig | null {
const source = typeof value === 'string' ? parseJsonOrNull(value) : value
if (!isRecord(source)) return null
const items: ScheduleItem[] = []
for (const raw of list(source.items)) {
if (items.length >= MAX_SCHEDULE_ITEMS) break
if (!isRecord(raw)) continue
const time = scheduleText(raw.time, SCHEDULE_TIME_MAX_CHARS)
const title = scheduleText(raw.title, SCHEDULE_TITLE_MAX_CHARS)
// 时间与标题都是空的条目没有任何可显示的信息,丢掉(否则轴上会多出一个空节点)。
if (time === '' && title === '') continue
const note = scheduleText(raw.note, SCHEDULE_NOTE_MAX_CHARS)
const item: ScheduleItem = { time, title }
if (note !== '') item.note = note
// 只有显式 true 才算已完成:坏值一律当未完成(宁可显示成「还没到」,不能把没办的说成办完了)。
if (raw.done === true) item.done = true
items.push(item)
}
return {
items,
visible: source.visible === true,
side: oneOf(source.side, SCHEDULE_SIDES, 'right'),
widthPercent: clampSidebarWidthPercent(source.widthPercent),
showNote: source.showNote !== false,
instanceOverrides: parseScheduleOverrides(source.instanceOverrides),
}
}
function parseJsonOrNull(value: string): unknown {
try {
return JSON.parse(value) as unknown
} catch {
return null
}
}
function parseScheduleOverrides(value: unknown): Record<string, ScheduleOverride> | undefined {
if (!isRecord(value)) return undefined
const overrides: Record<string, ScheduleOverride> = {}
let count = 0
for (const [key, raw] of Object.entries(value)) {
if (count >= SCHEDULE_OVERRIDE_MAX) break
// 只认三态里的 on/off:认不出的值(含 inherit)不需要落表,缺省就是 inherit。
if (raw !== 'on' && raw !== 'off') continue
const id = key.trim()
if (id === '' || Array.from(id).length > SCHEDULE_OVERRIDE_KEY_MAX_CHARS) continue
overrides[id] = raw
count++
}
return count === 0 ? undefined : overrides
}
export function parseSettings(source: unknown): DisplaySettings {
const rec = record(source)
const showIndividualBoard = bool(
@@ -485,6 +575,9 @@ export function parseSettings(source: unknown): DisplaySettings {
LIVE_SOURCES,
'auto',
)
// 赛事日程侧边栏:整份配置(条目 + 开关 + 左右 / 宽度 + 单屏覆盖)由服务端随设置下发,
// 这里**只做容错收窄**——坏形状 → null = 没有日程,绝不让它把整页设置解析带崩。
const schedule = parseScheduleConfig(settingValue(rec, 'display.schedule', 'display.schedule'))
return {
event: {
name: str(settingValue(rec, 'event.name', 'event.name')),
@@ -508,6 +601,7 @@ export function parseSettings(source: unknown): DisplaySettings {
fontBody: fontBody === '' ? null : fontBody,
liveEvents,
liveSource,
schedule,
},
standings: {
individualToTeamWeight: num(
+36
View File
@@ -3,6 +3,7 @@ import type {
Event,
EventResultGroup,
EventResultRow,
ScheduleItem,
SnapshotMessage,
StandingRow,
Team,
@@ -266,6 +267,32 @@ function buildAthletes(): AthleteStanding[] {
})
}
/**
* 示例日程(赛事日程侧边栏)。
*
* 刻意做得「难排」一些,这份数据就是排版与动效的验收样本:
* - 13 条(12 条以上才会触发围绕当前项开窗);
* - 第 7 条是**当前项**(前 6 条已完成、后 6 条未开始),三态一次全看到;
* - 含一条 29 字的超长标题与一条 33 字的超长备注(要换行 / 截断,但不许溢出、不许缩到下限以下);
* - 时间全是手填自由文本(`上午` / `待定` / `08:30` 混着来),系统不做任何解析。
*/
const MOCK_SCHEDULE_ITEMS: ScheduleItem[] = [
{ time: '07:40', title: '各代表队签到、领取号码布', done: true },
{ time: '08:00', title: '开幕式 · 代表队入场', done: true, note: '07:30 前在主席台东侧集合' },
{ time: '08:30', title: '男子 100 米预赛', done: true, note: '三组取前两名进决赛' },
{ time: '09:00', title: '女子 100 米预赛', done: true },
{ time: '09:30', title: '男子跳远决赛(中年组 / 青年组同场,按试跳轮次统一编排)', done: true,
note: '检录在场地西侧入口,三次点名未到按弃权处理,成绩以电子计时为准' },
{ time: '10:10', title: '女子 4×100 米接力预赛', done: true },
{ time: '上午', title: '拔河 · 小组循环赛', note: '每队 10 人,三局两胜' },
{ time: '11:30', title: '男子 1500 米决赛' },
{ time: '13:30', title: '趣味项目 · 两人三足' },
{ time: '14:30', title: '女子 4×100 米接力决赛' },
{ time: '15:20', title: '混合团体 · 拔河决赛' },
{ time: '待定', title: '颁奖仪式 · 单位团体总分前六名' },
{ time: '16:40', title: '闭幕式', note: '如遇雨天顺延,以现场广播为准' },
]
/**
* 示例配置。单列出来是因为它比共享契约多「实时赛况」两个键(后端未就绪时也要能演示):
* 直接写在 SnapshotMessage 字面量里会被多余属性检查拦下。
@@ -289,6 +316,15 @@ const MOCK_SETTINGS: DisplaySettings = {
// 示例数据里有 3 个已完成项目,取前两个演示双栏分栏。
liveEvents: [1, 2],
liveSource: 'manual',
// 赛事日程侧边栏:默认右侧 22%,备注上屏。示例数据里直接开着,
// 就是为了让「排版 / 三态 / 开窗 / 毛玻璃」在没有后端时也能被看到与验收。
schedule: {
items: MOCK_SCHEDULE_ITEMS,
visible: true,
side: 'right',
widthPercent: 22,
showNote: true,
},
},
standings: {
individualToTeamWeight: 1,
+509
View File
@@ -0,0 +1,509 @@
/**
* 大屏「赛事日程侧边栏」的纯函数层:开窗、三态、进度、字号与换行决策、单屏覆盖。
*
* 这里一行 DOM 都没有(组件在 components/ScheduleSidebar.vue),全部是可直接单测的算术。
* 拆开的原因:字号 / 换行 / 开窗是这套功能里唯一「算错了现场就看得出来」的部分
* (字太小、溢出容器、当前项被挤出屏幕),必须能脱离浏览器验。
*
* ⚠️ 双重缩放的坑(本文件所有 px 都是「舞台设计坐标」):
* 大屏的 `.stage` 是固定 1920×1080 的画布,外面套一层 `transform: scale()` 等比缩放到实际屏幕。
* 所以侧边栏里的所有尺寸都必须是**设计像素**:4K 上舞台被放大 2 倍,40 设计 px 自然变成 80 设备 px。
* 若拿 `window.innerWidth` / `vh` 去算字号,4K 会先算出 2 倍的字号、再被舞台放大 2 倍 = 4 倍,
* 这正是「别双重缩放」的意思。本文件的几何全部相对 `STAGE_CONTENT_*` 常量(见下),与视口无关。
*/
import type { ScheduleItem, ScheduleSidebarConfig, ScheduleOverride } from '@/shared'
import { MAX_ROW_FONT_PX, MIN_ROW_FONT_PX, fitRowFontPx, textEm } from './fitFont'
/**
* 舞台内容盒(设计 px)。
*
* 侧边栏挂在 `.stage` 里、`position:absolute; inset:0`。`.stage` 带 `transform: scale()`,
* 实测(真机量过:容器铺满 1920×1080)它同时是绝对定位后代的包含块,所以面板是**整屏满高**的,
* 与明细覆盖层一致。这里的 1824×1000 是**按舞台内边距盒算的保守预算**:
* 面板实际比它更大,布局按小一点的盒子算,最多浪费几十像素,绝不会溢出。
* 改 App.vue 的 `.stage` padding 时要同步这里,否则面板会顶出边界。
*/
export const STAGE_CONTENT_WIDTH_PX = 1824
export const STAGE_CONTENT_HEIGHT_PX = 1000
/** 面板内边距。与组件 CSS 里的 `var(--sb-pad-*)` 同源(由组件按这里的常量注入)。 */
export const SIDEBAR_PADDING_X_PX = 30
export const SIDEBAR_PADDING_Y_PX = 30
/** 竖向时间轴一列的宽度(节点 + 轴),以及它与正文之间的间距。 */
export const SIDEBAR_AXIS_COLUMN_PX = 44
export const SIDEBAR_AXIS_GAP_PX = 18
/**
* 表头(标题 + `已完成 x / 共 y` + 进度条)高度。
*
* 80px 是组件 CSS 实测(30px 字号一行 + 14px 间距 + 6px 进度条 + 14px 下边框),
* 再留 8px 余量:这几行 CSS 一改就要同步这里,不然布局会算得比真实空间大。
*/
export const SIDEBAR_HEADER_PX = 88
/** 「上方 / 下方还有 N 项」提示各占一行的高度。 */
export const SIDEBAR_HINT_PX = 32
/** 列表容器自己的上下外边距。 */
export const SIDEBAR_LIST_MARGIN_PX = 18
/** 宽度百分比的默认值与上下限,与服务端口径一致(服务端写库前夹到 15–40)。 */
export const DEFAULT_SIDEBAR_WIDTH_PERCENT = 22
export const MIN_SIDEBAR_WIDTH_PERCENT = 15
export const MAX_SIDEBAR_WIDTH_PERCENT = 40
/** 条目数上限,与服务端校验一致;超出的一律丢弃(越界值按服务端口径处理)。 */
export const MAX_SCHEDULE_ITEMS = 40
/** 副文本(时间 / 备注)字号 = 标题字号 × 这个比例,且不低于 MIN_NOTE_FONT_PX。 */
export const NOTE_FONT_RATIO = 0.8
/** 副文本硬下限:与标题下限同一个理由——10 米外看不清就等于没写。 */
export const MIN_NOTE_FONT_PX = MIN_ROW_FONT_PX
/** 标题最多排几行;再多就按显示宽度截断补省略号(绝不靠继续缩字号硬塞)。 */
export const MAX_TITLE_LINES = 3
/** 备注最多排几行。 */
export const MAX_NOTE_LINES = 2
/** 行高比例(相对各自字号),与组件 CSS 的 line-height 一致。 */
export const TITLE_LINE_HEIGHT = 1.25
export const NOTE_LINE_HEIGHT = 1.3
/** 一条目上下留白(相对标题字号)。 */
export const ROW_PADDING_FACTOR = 0.5
/** 高度安全系数:估算墨迹与真实字形的误差、以及亚像素取整都从这里让位。 */
const HEIGHT_SAFETY = 0.94
/** 字号固定点迭代的最大轮数(字号 ↔ 行数互相依赖,见 forCount)。 */
const MAX_FONT_ROUNDS = 4
/**
* 「字号过关」的起点:算出来的字号**高于**硬下限才算过关,否则宁可少显示一条再看。
*
* 这就是定稿口径「宁可行数少也不缩到看不清」在数字上的样子:16px 是「还能看」的底线,
* 正贴在底线上时,少排一条换来的那几 px 更值。想让现场尽量多看几条(密度优先),
* 把 scheduleLayout 里的 `>= COMFORTABLE_FONT_PX` 改成 `> MIN_ROW_FONT_PX` 即可。
*/
export const COMFORTABLE_FONT_PX = MIN_ROW_FONT_PX + 1
/**
* 一屏至少显示几条。
*
* 与「字号不低于 16px」是一对:字被挤到下限时宁愿少显示一条换更大的字,
* 但再少也不会少于这么多条——只剩一两条的「时间轴」没有意义。
*/
export const MIN_WINDOW_ROWS = 5
/** 节点的三种状态,决定轴上那一点的样式(已完成实心淡化 / 当前放大带光晕 / 未开始低对比)。 */
export type ScheduleNodeState = 'done' | 'current' | 'todo'
/** 开窗结果:`[start, end)` 是窗口内的下标区间,另外两个数给「上方 / 下方还有 N 项」用。 */
export interface ScheduleWindow {
start: number
end: number
hiddenBefore: number
hiddenAfter: number
}
export interface ScheduleProgress {
done: number
total: number
}
/**
* 「当前项」= 第一个未勾选完成的条目(定稿口径 2:不做时间比较——时间是完全手填的自由文本,
* 人工解析「上午」「待定」这类文本一定会出错)。
*
* 全部完成时返回 -1 = **没有当前项**:把最后一条已完成的项标成「当前」是在撒谎,
* 调用方(开窗 / 节点样式)都按 -1 处理,轴上不会出现光晕节点。
*/
export function currentScheduleIndex(items: readonly ScheduleItem[]): number {
for (let index = 0; index < items.length; index++) {
if (items[index]?.done !== true) return index
}
return -1
}
/** 整体进度读数(表头 `已完成 x / 共 y`)。 */
export function overallProgress(items: readonly ScheduleItem[]): ScheduleProgress {
let done = 0
for (const item of items) if (item.done === true) done++
return { done, total: items.length }
}
/** 单条的三态。已完成优先于「当前」判定(当前项按定义不可能是已完成的)。 */
export function scheduleNodeState(
item: ScheduleItem | undefined,
index: number,
currentIndex: number,
): ScheduleNodeState {
if (item?.done === true) return 'done'
return index === currentIndex ? 'current' : 'todo'
}
/**
* 围绕当前项开窗(定稿口径 1:放不下时绝不缩字号,而是少显示几条)。
*
* 规则:
* - 条目少于容量 → 全显示;
* - 有当前项 → 当前项固定在窗口中间(容量为偶数时它略偏上,前少后多各占一半);
* - 当前项靠近头尾 → 贴住边界(第一项在窗口顶部、最后一项在窗口底部),不留下空白行;
* - 没有当前项(全部完成 / 空列表)→ 贴住末尾:刚走过的那几条才是现场想看的。
*
* `capacity` 由 scheduleLayout 按可用高度算出来,这里只负责摆放。
*/
export function visibleScheduleWindow(
items: readonly ScheduleItem[],
currentIndex: number,
capacity: number,
): ScheduleWindow {
const total = items.length
if (total === 0) return { start: 0, end: 0, hiddenBefore: 0, hiddenAfter: 0 }
const size = Math.min(total, Math.max(1, Math.floor(capacity)))
let start: number
if (currentIndex < 0 || currentIndex >= total) {
start = total - size
} else {
start = currentIndex - Math.floor((size - 1) / 2)
start = Math.min(Math.max(0, start), total - size)
}
const end = start + size
return { start, end, hiddenBefore: start, hiddenAfter: total - end }
}
/** 副文本字号(时间 / 备注共用一级):主字号 × 0.8,且有硬下限。 */
export function scheduleNoteFontPx(fontPx: number): number {
const scaled = Math.round(fontPx * NOTE_FONT_RATIO)
return Math.max(MIN_NOTE_FONT_PX, scaled)
}
/**
* 标题在给定字号下要排几行(1..MAX_TITLE_LINES)。
*
* 用 `textEm` 按字符类别估宽(汉字 1.00em、数字 0.70em…),取值只许偏大,
* 所以算出来「排得下」时真机上一定排得下;返回值封顶在 MAX_TITLE_LINES,
* 超出的部分由 truncateScheduleText 补省略号,**不会**回头去缩字号。
*/
export function scheduleTitleLines(titleEm: number, columnPx: number, fontPx: number): number {
if (!(titleEm > 0) || !(columnPx > 0) || !(fontPx > 0)) return 1
const lines = Math.ceil((titleEm * fontPx) / columnPx)
return Math.min(MAX_TITLE_LINES, Math.max(1, lines))
}
/** 备注要排几行;0 = 这一条不排备注(总开关关着 / 没有备注 / 估宽为 0)。 */
export function scheduleNoteLines(
noteEm: number,
columnPx: number,
noteFontPx: number,
showNote: boolean,
): number {
if (!showNote || !(noteEm > 0) || !(columnPx > 0) || !(noteFontPx > 0)) return 0
const lines = Math.ceil((noteEm * noteFontPx) / columnPx)
return Math.min(MAX_NOTE_LINES, Math.max(1, lines))
}
/** 条目之间的竖向间距(随字号走,避免大字号时挤在一起)。 */
export function scheduleRowGapPx(fontPx: number): number {
return Math.min(28, Math.max(10, Math.round(fontPx * 0.6)))
}
/**
* 一条目的高度(设计 px):时间一行 + 标题若干行 + 备注若干行 + 上下留白。
*
* 与组件 CSS 一一对应(`.time` / `.note` 用 `--sb-note-font`、`.title` 用 `--sb-font`),
* 高度算错就会溢出面板,所以这里的比例都是显式常量而不是「差不多」。
*/
export function scheduleRowHeightPx(
fontPx: number,
titleLines: number,
noteLines: number,
hasTime: boolean,
): number {
const noteFontPx = scheduleNoteFontPx(fontPx)
const titles = Math.max(1, titleLines) * TITLE_LINE_HEIGHT * fontPx
const time = hasTime ? NOTE_LINE_HEIGHT * noteFontPx : 0
const notes = noteLines > 0 ? noteLines * NOTE_LINE_HEIGHT * noteFontPx : 0
return Math.ceil(titles + time + notes + ROW_PADDING_FACTOR * fontPx)
}
/**
* 标题字号(设计 px)。
*
* 两条约束取小:
* - **高度**:`rowCount` 行等分可用高度(`rowFactor` = 一行占几个字号),
* 直接复用 `fitRowFontPx` —— 它自带 16–56px 的硬上下限,是这套「字不能过大也不能过小」的现成口径;
* - **宽度**:最长的标题在 MAX_TITLE_LINES 行内排得下所需的字号上限
* (所以字多的条目会换来更小的字,但只会小到下限,再长就截断)。
*
* 返回值的下限恒为 MIN_ROW_FONT_PX:宁可截断,也不缩到 10 米外看不清。
*/
export function scheduleFontPx(
rowCount: number,
availablePx: number,
columnPx: number,
longestTitleEm: number,
rowFactor = 1.75,
): number {
const rows = Math.max(1, Math.floor(rowCount))
const factor = Number.isFinite(rowFactor) && rowFactor > 0 ? rowFactor : 1.75
const byHeight = fitRowFontPx(rows, availablePx * HEIGHT_SAFETY, 1 / factor, MAX_ROW_FONT_PX)
const byWidth =
longestTitleEm > 0 && columnPx > 0
? Math.floor((columnPx * MAX_TITLE_LINES) / longestTitleEm)
: MAX_ROW_FONT_PX
return Math.max(MIN_ROW_FONT_PX, Math.min(byHeight, byWidth, MAX_ROW_FONT_PX))
}
export interface ScheduleLayoutInput {
/** 列表里的条目总数(不是窗口大小)。 */
itemCount: number
/** 面板正文可用高度(设计 px,已扣掉内边距与表头表尾)。 */
availablePx: number
/** 一条目正文可用宽度(设计 px,已扣掉时间轴一列)。 */
columnPx: number
/** 窗口内最长标题的估宽(em)。 */
longestTitleEm: number
/** 窗口内最长备注的估宽(em)。 */
longestNoteEm: number
/** 这一屏的条目里是否出现过时间文本(决定要不要给每行留一行时间)。 */
hasTime: boolean
/** 备注总开关。 */
showNote: boolean
}
export interface ScheduleLayout {
/** 这一屏能放几条(= 开窗容量)。 */
capacity: number
fontPx: number
noteFontPx: number
titleLines: number
noteLines: number
/** 一条目的高度(含上下留白,不含条目之间的间距)。 */
rowHeightPx: number
rowGapPx: number
/** 一条目占的总高度(高度 + 间距),容量就是按它算的。 */
rowStridePx: number
}
/** 面板几何(设计 px):宽度百分比换算成面板宽、正文列宽与正文可用高度。 */
export interface SidebarGeometry {
panelPx: number
columnPx: number
availablePx: number
}
/** 宽度百分比夹到服务端认可的 15–40;坏值退回默认 22(服务端也会夹,这里只做防御)。 */
export function clampSidebarWidthPercent(value: unknown): number {
const num = typeof value === 'number' && Number.isFinite(value) ? value : NaN
if (!Number.isFinite(num)) return DEFAULT_SIDEBAR_WIDTH_PERCENT
return Math.round(
Math.min(MAX_SIDEBAR_WIDTH_PERCENT, Math.max(MIN_SIDEBAR_WIDTH_PERCENT, num)),
)
}
export function sidebarGeometry(widthPercent: number): SidebarGeometry {
const panelPx = Math.round((STAGE_CONTENT_WIDTH_PX * clampSidebarWidthPercent(widthPercent)) / 100)
const columnPx =
panelPx - 2 * SIDEBAR_PADDING_X_PX - SIDEBAR_AXIS_COLUMN_PX - SIDEBAR_AXIS_GAP_PX
// 正文可用高度:扣掉上下内边距、表头、两行「还有 N 项」提示、以及列表自己的上下外边距。
// 提示行按「两条都在」预留:窗口有没有被截断要等容量算出来才知道,先按最坏情况留。
const availablePx =
STAGE_CONTENT_HEIGHT_PX -
2 * SIDEBAR_PADDING_Y_PX -
SIDEBAR_HEADER_PX -
2 * SIDEBAR_HINT_PX -
2 * SIDEBAR_LIST_MARGIN_PX
return { panelPx, columnPx: Math.max(1, columnPx), availablePx: Math.max(1, availablePx) }
}
/**
* 字号与行数的最终决策(组件只调它一个)。
*
* 为什么要迭代:字号大了标题就占更多行、行更高 → 字号又得小;反过来也一样。
* 这里从最大字号开始做固定点迭代(最多 MAX_FONT_ROUNDS 轮),
* 再在访问过的候选里取「**真的排得下**且字号最大」的那一个,最后再从最大行数往下找容量:
* 条目少 → 容量就是条目数(全显示)、字号吃满高度;条目多 → 容量变小、字号停在下限之上。
*/
export function scheduleLayout(input: ScheduleLayoutInput): ScheduleLayout {
const itemCount = Math.max(0, Math.floor(input.itemCount))
const availablePx = Math.max(1, input.availablePx)
const columnPx = Math.max(1, input.columnPx)
const showNote = input.showNote && input.longestNoteEm > 0
const forCount = (count: number): ScheduleLayout => {
const at = (fontPx: number): ScheduleLayout => {
const titleLines = scheduleTitleLines(input.longestTitleEm, columnPx, fontPx)
const noteFontPx = scheduleNoteFontPx(fontPx)
const noteLines = scheduleNoteLines(input.longestNoteEm, columnPx, noteFontPx, showNote)
const rowHeightPx = scheduleRowHeightPx(fontPx, titleLines, noteLines, input.hasTime)
const rowGapPx = scheduleRowGapPx(fontPx)
return {
capacity: count,
fontPx,
noteFontPx,
titleLines,
noteLines,
rowHeightPx,
rowGapPx,
rowStridePx: rowHeightPx + rowGapPx,
}
}
let current = at(MAX_ROW_FONT_PX)
const seen: ScheduleLayout[] = [current]
for (let round = 0; round < MAX_FONT_ROUNDS; round++) {
const next = at(
scheduleFontPx(
count,
availablePx,
columnPx,
input.longestTitleEm,
current.rowStridePx / Math.max(1, current.fontPx),
),
)
if (next.fontPx === current.fontPx) break
current = next
seen.push(next)
}
let best: ScheduleLayout | null = null
for (const candidate of seen) {
if (!layoutFits(candidate, count, availablePx)) continue
if (best === null || candidate.fontPx > best.fontPx) best = candidate
}
return best ?? current
}
if (itemCount === 0) return forCount(0)
// 理论上能放下的最大行数:按最小字号 + 最少行数算,再往上一档就是物理上放不下。
const floorStride = scheduleRowHeightPx(MIN_ROW_FONT_PX, 1, showNote ? 1 : 0, input.hasTime)
const maxRows = Math.max(1, Math.floor(availablePx / floorStride))
const upper = Math.min(itemCount, maxRows)
const minRows = Math.min(itemCount, MIN_WINDOW_ROWS)
let widest: ScheduleLayout | null = null
for (let count = upper; count >= 1; count--) {
const candidate = forCount(count)
if (!layoutFits(candidate, count, availablePx)) continue
// 最大的可行行数:一路让到「最少显示几条」都还放不下时才用它兜底。
if (widest === null) widest = candidate
if (candidate.fontPx >= COMFORTABLE_FONT_PX) return candidate
// 字号已经贴死在硬下限上:这时少显示一条能换来更大的字,就少显示一条
// (定稿口径:宁可显示的行数少,也不缩到看不清),但不再少于 MIN_WINDOW_ROWS 条。
if (count <= minRows) return candidate
}
return widest ?? forCount(1)
}
function layoutFits(layout: ScheduleLayout, count: number, availablePx: number): boolean {
if (count <= 0) return true
return count * layout.rowHeightPx + (count - 1) * layout.rowGapPx <= availablePx
}
/**
* 按**显示宽度**(em)截断,超出补省略号。
*
* 与 truncateDeviceLabel 的口径不同:那边按码点个数切(设备标签是等宽的拉丁字符),
* 这里必须按估宽切——「III」和「测试」的字符数一样、占的宽度差一倍。
* 省略号自己也占位,所以先把它从预算里扣掉,保证截断后的总宽**不会**超过 maxEm。
*/
export function truncateScheduleText(text: string, maxEm: number): string {
if (!(maxEm > 0)) return ''
if (textEm(text) <= maxEm) return text
const budget = maxEm - textEm('…')
if (budget <= 0) return '…'
let out = ''
let used = 0
for (const ch of text) {
const width = textEm(ch)
if (used + width > budget) break
out += ch
used += width
}
return out === '' ? '…' : `${out}…`
}
export interface ResolvedSidebarConfig {
enabled: boolean
side: 'left' | 'right'
widthPercent: number
showNote: boolean
}
/**
* 把单屏覆盖(inherit / on / off)套到总开关上(定稿口径 8:键是**实例 id**)。
*
* 覆盖键认不出(不在表里)就是 inherit,与「没配过」完全一样;
* `instanceId` 为空字符串时**不做任何匹配**——空键要是能命中,一块屏的临时覆盖会误伤所有
* 报不出 id 的屏(老服务端 / 存储不可用)。
*/
export function resolveSidebarConfig(
config: ScheduleSidebarConfig | null | undefined,
instanceId: string,
): ResolvedSidebarConfig {
const side = config?.side === 'left' ? 'left' : 'right'
const widthPercent = clampSidebarWidthPercent(config?.widthPercent)
// 备注默认上屏(定稿口径 5):缺字段 / 坏值都当成「上屏」,只有显式 false 才关。
const showNote = config?.showNote !== false
if (config === null || config === undefined) {
return { enabled: false, side, widthPercent, showNote }
}
const override = scheduleOverrideFor(config.instanceOverrides, instanceId)
const enabled =
override === 'on' ? true : override === 'off' ? false : config.visible === true
return { enabled, side, widthPercent, showNote }
}
/** 取一块屏的覆盖值;认不出就是 inherit。 */
export function scheduleOverrideFor(
overrides: Record<string, ScheduleOverride> | undefined,
instanceId: string,
): ScheduleOverride {
if (overrides === undefined || instanceId === '') return 'inherit'
const value = overrides[instanceId]
return value === 'on' || value === 'off' ? value : 'inherit'
}
/**
* 明细覆盖层 / 全屏公告期间自动收起(定稿口径 6)。
*
* 只是**渲染期**的抑制,开关状态一个字节都不动:收起结束(明细关闭 / 公告退场)后原样回来。
* 这也是侧边栏与 detail 状态机唯一的接触点——它只读 detail,绝不参与开关明细。
*/
export function sidebarSuppressed(detailOpen: boolean, announceFullscreen: boolean): boolean {
return detailOpen || announceFullscreen
}
/**
* 配置指纹:指令(热开关)与设置(持久化)两条来源的同源去重。
*
* 服务端保存日程时会「先转发指令、再落库」,落库后大屏还会收到一份一模一样的设置值;
* 不去重就会让面板重建一次(入场动效重放、看起来像闪了一下)。
* 指纹只取影响画面的字段,且顺序稳定(覆盖键排序),所以两条来源能可靠地判等。
*/
export function scheduleSignature(config: ScheduleSidebarConfig | null | undefined): string {
if (config === null || config === undefined) return ''
const overrides = Object.entries(config.instanceOverrides ?? {})
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([key, value]) => `${key}=${value}`)
.join(',')
const items = config.items
.map((item) => `${item.time}\u0001${item.title}\u0001${item.note ?? ''}\u0001${item.done === true ? 1 : 0}`)
.join('\u0002')
return [
config.visible === true ? 1 : 0,
config.side,
clampSidebarWidthPercent(config.widthPercent),
config.showNote === false ? 0 : 1,
overrides,
items,
].join('\u0003')
}
@@ -0,0 +1,116 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseScheduleConfig, parseSettings } from '../guards'
/**
* 容错解析:大屏是无人值守的,`display.schedule` 这一键坏了**只许坏这一键**。
* 这里逐条钉住「缺 / 坏 → null(= 没有日程)」「越界值按服务端口径夹」「坏条目丢弃」。
*/
test('缺字段 / 坏形状一律 null,绝不抛错', () => {
for (const bad of [undefined, null, 0, '', '不是 JSON', [], true, () => 1]) {
assert.equal(parseScheduleConfig(bad), null, `${String(bad)} 应该解析成 null`)
}
})
test('对象形状:缺字段按默认值补齐(关着 + 右侧 22% + 备注上屏)', () => {
assert.deepEqual(parseScheduleConfig({}), {
items: [],
visible: false,
side: 'right',
widthPercent: 22,
showNote: true,
instanceOverrides: undefined,
})
})
test('条目:坏形状丢弃、控制字符清洗、超长按服务端口径截断', () => {
const parsed = parseScheduleConfig({
visible: true,
items: [
{ time: '08:30', title: '男子 100 米预赛' },
'not-an-object',
{ time: '', title: '' },
{ time: ' ', title: ' ' },
{ time: '09:00', title: 'a\nb\tc' },
{ time: 'x'.repeat(50), title: 't'.repeat(50), note: 'n'.repeat(80) },
{ time: '上午', title: '拔河', done: true },
{ time: '待定', title: '颁奖', done: 'yes' },
],
})
assert.ok(parsed !== null)
const items = parsed.items
assert.equal(items.length, 5, '3 个坏条目被丢掉')
assert.deepEqual(items[0], { time: '08:30', title: '男子 100 米预赛' })
assert.equal(items[1]?.title, 'abc', '控制字符清洗掉')
assert.equal(items[2]?.time.length, 20, 'time 上限 20 字符')
assert.equal(items[2]?.title.length, 40, 'title 上限 40 字符')
assert.equal(items[2]?.note?.length, 60, 'note 上限 60 字符')
assert.equal(items[3]?.done, true)
assert.equal(items[4]?.done, undefined, 'done 只认布尔 true,坏值当未完成')
assert.equal(items[0]?.note, undefined, '空备注不落字段')
})
test('条目数上限 40,超出的丢弃', () => {
const many = Array.from({ length: 60 }, (_, index) => ({ time: `${index}`, title: `第 ${index} 项` }))
const parsed = parseScheduleConfig({ visible: true, items: many })
assert.equal(parsed?.items.length, 40)
})
test('开关与越界值:宽度夹到 15–40,side 只认 left/right', () => {
assert.equal(parseScheduleConfig({ widthPercent: 5, side: 'left' })?.widthPercent, 15)
assert.equal(parseScheduleConfig({ widthPercent: 99 })?.widthPercent, 40)
assert.equal(parseScheduleConfig({ widthPercent: '很大' })?.widthPercent, 22)
assert.equal(parseScheduleConfig({ widthPercent: 26.6 })?.widthPercent, 27)
assert.equal(parseScheduleConfig({ side: 'top' })?.side, 'right')
assert.equal(parseScheduleConfig({ side: 'left' })?.side, 'left')
// visible 只认布尔 true:`"true"` 这种字符串不算开着。
assert.equal(parseScheduleConfig({ visible: 'true' })?.visible, false)
assert.equal(parseScheduleConfig({ showNote: false })?.showNote, false)
})
test('单屏覆盖:只留 on/off,键做清洗与长度限制', () => {
const parsed = parseScheduleConfig({
instanceOverrides: {
screenA: 'on',
screenB: 'off',
screenC: 'inherit',
screenD: 'ON',
' screenE ': 'off',
'': 'on',
[('k'.repeat(200))]: 'on',
},
})
assert.deepEqual(parsed?.instanceOverrides, { screenA: 'on', screenB: 'off', screenE: 'off' })
assert.equal(parseScheduleConfig({ instanceOverrides: {} })?.instanceOverrides, undefined)
assert.equal(parseScheduleConfig({ instanceOverrides: 'nope' })?.instanceOverrides, undefined)
})
test('允许 JSON 字符串(settings 表里存的是文本)', () => {
const json = JSON.stringify({ items: [{ time: '08:00', title: '开幕式' }], visible: true })
const parsed = parseScheduleConfig(json)
assert.equal(parsed?.visible, true)
assert.equal(parsed?.items[0]?.title, '开幕式')
})
test('parseSettings:schedule 坏掉不影响同一包里别的设置', () => {
const settings = parseSettings({
display: { title: '2026 职工运动会', rotateSeconds: 20, schedule: '坏值' },
standings: { showIndividualBoard: false },
})
assert.equal(settings.display.schedule, null)
assert.equal(settings.display.title, '2026 职工运动会', '同一包里的标题照旧解析出来')
assert.equal(settings.display.rotateSeconds, 20)
assert.equal(settings.standings.showIndividualBoard, false)
const good = parseSettings({
display: { title: 'x', schedule: { items: [{ time: '08:00', title: '开幕式' }], visible: true } },
})
assert.equal(good.display.schedule?.items.length, 1)
assert.equal(good.display.schedule?.visible, true)
})
test('parseSettings:老服务端不带 schedule 时是 null(= 大屏上没有这个功能)', () => {
const settings = parseSettings({ display: { title: 'x' } })
assert.equal(settings.display.schedule, null)
})
@@ -0,0 +1,386 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { ScheduleItem, ScheduleSidebarConfig } from '@/shared'
import { MAX_ROW_FONT_PX, MIN_ROW_FONT_PX, textEm } from '../fitFont'
import {
MAX_TITLE_LINES,
STAGE_CONTENT_WIDTH_PX,
clampSidebarWidthPercent,
currentScheduleIndex,
overallProgress,
resolveSidebarConfig,
scheduleFontPx,
scheduleLayout,
scheduleNodeState,
scheduleNoteLines,
scheduleRowHeightPx,
scheduleSignature,
scheduleTitleLines,
sidebarGeometry,
sidebarSuppressed,
truncateScheduleText,
visibleScheduleWindow,
} from '../schedulePanel'
function item(time: string, title: string, extra: Partial<ScheduleItem> = {}): 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), '')
})
@@ -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(/<!--[\s\S]*?-->/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('侧边栏挂在 <DetailOverlay /> 之后,且在 .stage 里', () => {
const overlay = APP.indexOf('<DetailOverlay />')
const sidebar = APP.indexOf('<ScheduleSidebar />')
assert.ok(overlay > 0, 'DetailOverlay 还在')
assert.ok(sidebar > overlay, '侧边栏必须挂在明细覆盖层之后(覆盖层在最上层)')
assert.equal(APP.split('<ScheduleSidebar />').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, /<div v-if="visible" class="schedule-layer">/)
assert.doesNotMatch(CODE, /<Transition/, '不用 Transition:它会留下注释占位节点')
assert.match(COMPONENT, /animation:\s*sb-in/, '入场动效改走 CSS animation')
})
test('深色半透明 + 毛玻璃,且有 @supports 退化', () => {
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}`)
}
})
+49
View File
@@ -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<DisplayUrlOptions>({
resultsCount: 3,
})
const detail = ref<DetailOverlayState | null>(null)
/**
* 赛事日程侧边栏的当前配置(**热开关**)。
*
* 两个来源:`display:command` 的 schedule 指令(立刻生效)与设置里的 `display.schedule`
* (服务端持久值,刷新 / 重启不丢)。两者内容同源,靠指纹去重,见 applyScheduleConfig。
*
* 与 `detail` 状态机**完全无关**:这里只是一个配置 ref,`goto` 关明细、明细停轮播都不碰它。
*/
const schedule = ref<ScheduleSidebarConfig | null>(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,
+49
View File
@@ -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<string, ScheduleOverride>
}
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 {