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:
1 parent
56ed90ff8b
commit
9c52dc4b38
33 files changed
+4736
-10
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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}%`
|
||||
}
|
||||
@@ -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\)/)
|
||||
})
|
||||
@@ -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%')
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user