feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相

## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发)
- 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、
  手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。
- 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错
  (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。

## 实时赛况(新大屏页 + 定向接口)
- 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩
  (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。
  设置键 display.live_events / display.live_source。
- 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面
  (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。
- 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only),
  故对非管理员整组置灰 + 提示条(不新增角色)。

## 显式批次层(组别之下)
- 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果
  完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 +
  一键均分(按 N 人切片,已有批次号接着往后排)。

## 成绩输入框实时悬浮提示
- 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻;
  合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文;
  与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。

## 窄屏 / 手机端布局
- 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针,
  四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框
  塌成 22px 这类真问题。

## 版本号单一真相
- 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json
  的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。

测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
This commit is contained in:
AskaEth committed 2026-09-11 04:05:02 +08:00
1 parent e5e0944d26
commit 08a5740fb2
64 files changed
+4968 -270

No files matched your search

+22
View File
@@ -6,10 +6,13 @@ import type {
AuditLog,
CurrentUser,
DisplayCommand,
EntryHeatPayload,
EntryHeatResult,
EntryWithResult,
Event,
EventSettings,
HealthInfo,
LiveEvents,
Page,
PageQuery,
PointAdjustment,
@@ -139,6 +142,13 @@ export const adminApi = {
entries: (eventId: number): Promise<EntryWithResult[]> =>
call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)),
/**
* 批量设置 / 清除批次(heat)。写操作不降级:示例数据模式下没有后端可落库,
* 页面会先看 data.isMock 再决定是否调用。
*/
setEntriesHeat: (payload: EntryHeatPayload): Promise<EntryHeatResult> =>
callWrite(() => client.entries.setHeat(payload)),
saveDraft: (payload: SaveDraftPayload): Promise<Result[]> =>
callWrite(() => client.results.saveDraft(payload)),
@@ -198,6 +208,18 @@ export const adminApi = {
() => [],
),
/**
* 「实时赛况」当前项目(服务端按设置与时序判定)。
*
* 判定的依据是排期与最近一次发布,随赛事进程自己变化;示例数据里没有这套时序,
* 连不上时给空结论、由页面显示「当前没有可显示的项目」,比弹错更有用。
*/
displayLive: (): Promise<LiveEvents> =>
call(
() => client.display.live(),
() => ({ source: 'auto', eventIds: [], events: [] }),
),
login: (username: string, password: string): Promise<CurrentUser> =>
callWrite(() => client.auth.login(username, password)),
+1
View File
@@ -369,6 +369,7 @@ class MockDb {
memberNames: seed.eventType === 'individual' ? null : [`${team.shortName ?? team.name}代表队`],
bib: seed.eventType === 'individual' ? String(entryId % 1000) : null,
seed: null,
heat: null,
status: 'registered',
remark: null,
athleteName,
@@ -20,11 +20,12 @@ import {
const data = useBaseData()
const toast = useToastStore()
// heat 列是批次:留空 = 未分批,也可以直接填正整数(如 1、2)。
const TEMPLATE_CSV = [
REQUIRED_COLUMNS.join(','),
'M100,男子组,F1,张伟,110101199001011234,男,26,101,,registered,',
'W100,女子组,F2,王芳,,女,,202,,registered,',
'BB,混合组,F2,,,,,,种子1,registered,第一分公司代表队',
'M100,男子组,F1,张伟,110101199001011234,男,26,101,,1,registered,',
'W100,女子组,F2,王芳,,女,,202,,2,registered,',
'BB,混合组,F2,,,,,,种子1,,registered,第一分公司代表队',
].join('\n')
const MAX_SIZE = 16 * 1024 * 1024
@@ -189,8 +190,9 @@ function downloadTemplate(): void {
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age 可缺省,
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
证件号与号码布都是人员唯一标识:
先按证件号匹配,未命中再按号码布匹配,仍未命中则按「姓名 + 单位」匹配库里证件号为空的人并回填。
</span>
@@ -13,7 +13,11 @@ import type {
ImportConflictField,
} from '@/shared'
/** 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。 */
/**
* 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。
*
* heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。
*/
export const REQUIRED_COLUMNS = [
'event_code',
'category',
@@ -24,12 +28,13 @@ export const REQUIRED_COLUMNS = [
'age',
'bib',
'seed',
'heat',
'status',
'member_names',
] as const
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age'] as const
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat'] as const
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS
@@ -92,6 +97,8 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
'',
athlete.bib ?? '',
'',
// 批次留空 = 未分批:批次在录入页按现场编排(批量设置 / 一键均分)。
'',
status,
'',
])
@@ -142,6 +149,8 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
'',
'',
'',
// 批次留空 = 未分批,与个人项目一致。
'',
status,
team.memberNames.join('/'),
])
+108 -1
View File
@@ -118,6 +118,12 @@ onMounted(async () => {
<template>
<div class="mx-app admin-shell">
<div
v-if="sidebarOpen"
class="sidebar-backdrop"
aria-hidden="true"
@click="sidebarOpen = false"
/>
<aside class="mx-sidebar" :class="{ open: sidebarOpen }">
<div class="mx-logo brand">
<img class="app-icon" src="/scoreboard.png" alt="" width="28" height="28" />
@@ -157,7 +163,7 @@ onMounted(async () => {
<MxIcon name="logs" :size="18" />
</button>
<div class="mx-breadcrumb">
<span>管理面板</span>
<span class="crumb-root">管理面板</span>
<span class="mx-breadcrumb-sep">/</span>
<span>{{ currentTitle }}</span>
</div>
@@ -237,9 +243,31 @@ onMounted(async () => {
height: 100dvh;
overflow: hidden;
}
/*
* 桌面壳是 .mx-app(≥1024 为 row)的 flex 子项:vendor 给的是 width:100%,
* 会按容器整宽撑开,再叠加 240px 侧边栏就把右侧内容顶到视口外;
* .admin-shell 又是 overflow:hidden,多出来的部分直接被裁掉(1024 下表格右侧列、
* 顶栏按钮全部消失)。flex:1 + min-width:0 让壳按剩余宽度收缩,
* 宽表格的横向滚动条才能落在卡片内部而不是撑破整页。
*/
.mx-desktop-shell {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.mx-desktop-main {
min-width: 0;
}
.menu-btn {
display: none;
}
.sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
z-index: 480;
background: rgba(0, 0, 0, 0.45);
}
.brand {
padding: 6px 10px 14px;
}
@@ -270,6 +298,18 @@ onMounted(async () => {
align-items: center;
gap: 10px;
}
/*
* 顶栏左侧:vendor 只有 .mx-topbar-left,没有 .topbar-left,
* 不补这条的话菜单按钮与面包屑会各占一行堆成 58px 高,把 52px 顶栏顶穿
* (按钮顶端被视口裁掉,手机上点不到)。
*/
.topbar-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1 1 auto;
}
/* 窄屏下 vendor 不提供桌面顶栏样式,这里补一份,保证 1024 以下也能用。 */
.topbar {
display: flex;
@@ -339,6 +379,17 @@ onMounted(async () => {
/* vendor 的 layout.css 在窄屏隐藏桌面壳,管理端必须始终显示主内容区。 */
.mx-desktop-shell {
display: flex !important;
/* vendor 只在 ≥1024 的规则里给了 flex-direction:column;
窄屏下必须自己补一份,否则顶栏与主内容区并排成一行(row),
主内容区被压成几十像素宽——窄屏"整页空白"就是这么来的。 */
flex-direction: column;
width: 100%;
min-width: 0;
}
.mx-desktop-main {
/* 窄屏收紧留白,把宽度让给内容 */
padding: 14px 14px 28px;
min-width: 0;
}
.menu-btn {
display: inline-flex;
@@ -347,15 +398,71 @@ onMounted(async () => {
position: fixed;
inset: 0 auto 0 0;
z-index: 500;
width: min(280px, 84vw);
max-width: 100%;
transform: translateX(-100%);
transition: transform var(--mx-d2) var(--mx-ease);
display: block;
box-shadow: var(--mx-sh3);
}
.mx-sidebar.open {
transform: translateX(0);
}
.sidebar-backdrop {
display: block;
}
.who-text {
display: none;
}
/* 抽屉在平板宽度也要用,vendor 只在 ≤767 给了 44px,这里补到整个窄屏区间。 */
.mx-nav-item {
min-height: 44px;
}
}
@media (max-width: 767px) {
/* 手机端顶栏只保留:菜单 / 当前页名 / 连接徽标 / 主题 / 刷新 / 头像 / 退出。
版本号与面包屑前缀会把 390px 顶栏顶到屏幕外,这里逐项减负。 */
.topbar {
padding: 0 10px;
gap: 8px;
}
.topbar-left {
min-width: 0;
flex: 1 1 auto;
}
.topbar-right {
gap: 2px;
flex-shrink: 0;
}
.version {
display: none;
}
/* 只留当前页名:前缀"管理面板 /"占掉一半宽度,会把标题挤成省略号。 */
.crumb-root,
.mx-breadcrumb-sep {
display: none;
}
.mx-breadcrumb {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.readonly-banner,
.mock-banner {
flex-wrap: wrap;
}
.readonly-banner .mx-btn,
.error-banner .mx-btn {
margin-left: 0;
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
/* 超窄屏再把头像让出来给当前页名,登录人身份在窄屏不是必须信息。 */
.who {
display: none;
}
}
</style>
@@ -133,6 +133,23 @@ async function submit(): Promise<void> {
display: flex;
flex-direction: column;
}
/*
* 这几个输入框没有 .mx-input-wrap 撑高,而 vendor 的 .mx-input 是 height:100%,
* 结果塌成 22px 的一条线:既看不出是输入框,手机上也不好点。
* 管理端的裸 .mx-input 在这里补一个实际高度。
*/
.password-form .mx-input {
height: 44px;
padding: 0 14px;
background: var(--mx-s3);
border: 1px solid var(--mx-border);
border-radius: var(--mx-r-sm);
}
.password-form .mx-input:focus {
outline: none;
border-color: var(--mx-primary);
box-shadow: 0 0 0 3px var(--mx-primary-glow);
}
.password-form .mx-label:not(:first-child) {
margin-top: 16px;
}
+286 -5
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { formatDateTime } from '@/shared'
import { formatDateTime, type EventSettings, type LiveEvents } from '@/shared'
import type { DisplayInstance } from '@/shared/api'
import { DISPLAY_PAGES, type DisplayPageKey } from '@/display/types'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client'
import { settingsApi } from '../components/system/systemApi'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
@@ -31,15 +32,129 @@ const pending = ref<string | null>(null)
*
* 先取启动时加载到的设置,再向 GET /api/settings 核对一次:公告是别的管理员推的、
* 或者本页面开着时被推过,本地这份设置可能是旧的。
* 同一份设置也带「实时赛况」的当前项目配置,一起刷新省一次请求。
*/
const currentAnnounce = ref<string | null>(data.settings?.display?.announce ?? null)
/** 最近一次读到的完整设置,保存实时赛况时要拿它拼出完整的 display 块。 */
const settingsSnapshot = ref<EventSettings | null>(data.settings ?? null)
// ── 实时赛况:当前项目(人工指定 / 自动判定) ──────────────────────────────
const LIVE_SOURCE_OPTIONS: Array<{ value: 'auto' | 'manual'; label: string; hint: string }> = [
{ value: 'auto', label: '自动判定', hint: '人工指定 → 排期命中(开始时间前后 30 分钟)→ 最近发布过成绩的项目' },
{ value: 'manual', label: '人工指定', hint: '只显示下面指定的项目,不做任何回退' },
]
const liveSource = ref<'auto' | 'manual'>('auto')
/** 人工指定的当前项目 id,最多 2 个;0 表示该槽位为空。 */
const liveEvents = ref<number[]>([])
const liveSaving = ref(false)
const liveResolved = ref<LiveEvents | null>(null)
const liveLoading = ref(false)
const LIVE_REASON_LABELS: Record<string, string> = {
manual: '人工指定',
scheduled: '排期命中',
recent: '最近发布',
}
const liveResolvedText = computed(() => {
const resolved = liveResolved.value
if (resolved === null || resolved.events.length === 0) return '当前没有可显示的项目'
return resolved.events
.map((item) => `${item.event.name}(${LIVE_REASON_LABELS[item.reason] ?? item.reason})`)
.join('、')
})
/** 下拉里只列真实小项;已删除的项目不会出现在这里。 */
const eventOptions = computed(() => data.events.map((event) => ({ id: event.id, name: event.name })))
function pickAt(index: number): number {
return liveEvents.value[index] ?? 0
}
/** 同一个项目不能同时占两个槽位:写入时去重,空槽位(0)自然被挤到后面。 */
function setPick(index: number, event: Event): void {
const value = Number((event.target as HTMLSelectElement).value)
const next = [...liveEvents.value]
next[index] = Number.isFinite(value) ? value : 0
const cleaned: number[] = []
for (const id of next) {
if (id > 0 && !cleaned.includes(id)) cleaned.push(id)
}
liveEvents.value = cleaned.slice(0, 2)
}
function clearPick(index: number): void {
liveEvents.value = liveEvents.value.filter((_, i) => i !== index)
}
async function refreshLive(): Promise<void> {
liveLoading.value = true
try {
liveResolved.value = await adminApi.displayLive()
} catch {
// 兜底:页面出错也不要把「当前项目」清成未知。
} finally {
liveLoading.value = false
}
}
async function saveLive(): Promise<void> {
if (!canWrite.value) {
toast.error('当前不可保存', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
// 按钮虽然已置灰,键盘/程序化触发仍可能走到这里;给一条明确的权限回答而不是等 403。
if (!isAdmin.value) {
toast.error('需要管理员权限', '实时赛况保存在赛事设置里(PUT /api/settings),仅管理员可修改')
return
}
if (data.isMock) {
toast.error('示例数据模式不能保存', '后端未就绪,改动不会写入真实赛事')
return
}
const base = settingsSnapshot.value
if (base === null) {
toast.error('读不到当前设置', '请先刷新页面再试')
return
}
const ids = liveEvents.value.filter((id) => id > 0).slice(0, 2)
liveSaving.value = true
try {
const result = await settingsApi.save({
display: { ...base.display, liveEvents: ids, liveSource: liveSource.value },
})
settingsSnapshot.value = result.data
liveEvents.value = [...(result.data.display?.liveEvents ?? ids)]
await refreshLive()
toast.success('实时赛况设置已保存', '大屏将按新配置显示当前项目')
} catch (caught) {
toast.error('保存实时赛况失败', caught instanceof Error ? caught.message : null)
} finally {
liveSaving.value = false
}
}
async function refreshSettings(): Promise<void> {
try {
const settings = await adminApi.client.settings.get()
settingsSnapshot.value = settings
currentAnnounce.value = settings.display?.announce ?? null
liveSource.value = settings.display?.liveSource === 'manual' ? 'manual' : 'auto'
liveEvents.value = [...(settings.display?.liveEvents ?? [])]
} catch {
// 示例数据 / 断网时读不到设置,保留本地这一份,不能把「当前公告」清成未知。
}
}
/** 只核对公告:推送 / 清除公告后调用,不能顺手覆盖管理员正在编辑的实时赛况配置。 */
async function refreshAnnounce(): Promise<void> {
try {
const settings = await adminApi.client.settings.get()
currentAnnounce.value = settings.display?.announce ?? null
} catch {
// 示例数据 / 断网时读不到设置,保留本地这一份,不能把「当前公告」清成未知。
// 同上:读不到就保留本地这份。
}
}
@@ -204,7 +319,8 @@ function onVisibilityChange(): void {
onMounted(() => {
void loadDisplays()
void refreshAnnounce()
void refreshSettings()
void refreshLive()
void refreshInstances()
startPolling()
document.addEventListener('visibilitychange', onVisibilityChange)
@@ -218,6 +334,21 @@ onBeforeUnmount(() => {
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const onlineCount = computed(() => instances.value.filter((instance) => instance.online).length)
/**
* 权限口径与系统设置各面板一致(不新增角色)。
*
* 本页大多数写操作走 POST /api/display/command(recorder 即可,canWrite 已覆盖),
* 但「实时赛况」这一组保存走 PUT /api/settings,是 admin-only:记录员点保存只会吃 403。
* 既然权限不放开,界面就按同一口径提前置灰并说明原因,别让人填完才失败。
*/
const isAdmin = computed(() => auth.user?.role === 'admin')
/** 非 null 表示实时赛况这组控件被权限挡住;同一句话既做提示条也做 title 悬浮说明。 */
const liveGateReason = computed(() => {
if (auth.readOnly) return '请先登录后再修改实时赛况。'
if (!isAdmin.value) return '只有管理员能修改实时赛况,当前账号为只读。'
return null
})
/**
* 实例的显示名:有屏号用屏号,局域网浏览器没有屏号,退回来源 IP,都用不上才退回实例 id。
* 只用于提示语,表格里始终显示完整 id。
@@ -487,6 +618,90 @@ async function send(
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">实时赛况</span>
<span class="mx-badge" :class="liveSource === 'manual' ? 'mx-badge-neutral' : 'mx-badge-info'">
{{ liveSource === 'manual' ? '人工指定' : '自动判定' }}
</span>
</div>
<div class="mx-card-body">
<p v-if="liveGateReason !== null" class="notice">{{ liveGateReason }}</p>
<div class="live-source">
<label
v-for="option in LIVE_SOURCE_OPTIONS"
:key="option.value"
class="live-source-option"
:class="{ active: liveSource === option.value, locked: liveGateReason !== null }"
:title="liveGateReason ?? undefined"
>
<input
type="radio"
name="live-source"
:value="option.value"
:checked="liveSource === option.value"
:disabled="liveGateReason !== null || liveSaving"
:title="liveGateReason ?? undefined"
@change="liveSource = option.value"
/>
<span class="live-source-text">{{ option.label }}</span>
</label>
</div>
<p class="hint live-hint">
{{ LIVE_SOURCE_OPTIONS.find((option) => option.value === liveSource)?.hint }}
</p>
<div class="live-picks">
<div v-for="index in 2" :key="index" class="live-pick">
<span class="live-pick-label">项目 {{ index }}</span>
<select
class="mx-cell-input live-pick-select"
:value="pickAt(index - 1)"
:disabled="liveGateReason !== null || liveSaving"
:title="liveGateReason ?? undefined"
@change="setPick(index - 1, $event)"
>
<option :value="0">未指定</option>
<option v-for="item in eventOptions" :key="item.id" :value="item.id">{{ item.name }}</option>
</select>
<button
v-if="pickAt(index - 1) > 0"
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="liveGateReason !== null || liveSaving"
:title="liveGateReason ?? undefined"
@click="clearPick(index - 1)"
>
清除
</button>
</div>
</div>
<div class="announce-foot">
<span class="live-resolved">
当前生效:<strong v-if="!liveLoading">{{ liveResolvedText }}</strong>
<span v-else>读取中…</span>
</span>
<div class="announce-actions">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="liveLoading" @click="refreshLive">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="liveGateReason !== null || liveSaving"
:title="liveGateReason ?? undefined"
@click="saveLive"
>
<MxIcon name="check" :size="15" />
{{ liveSaving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<p class="hint">当前项目最多 2 个。保存后大屏的「实时赛况」页按此显示。</p>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">显示器</span>
@@ -715,6 +930,19 @@ async function send(
.hint {
margin-top: 12px;
}
/*
* 权限提示条:与系统设置各面板(EventInfoPanel / UsersPanel / BackupPanel / ExportPage)
* 的 .notice 同一套写法与取值,避免同一句「只有管理员能…」在这里长成另一个样子。
*/
.notice {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
margin-bottom: 12px;
}
.textarea-wrap {
height: auto;
align-items: flex-start;
@@ -785,8 +1013,7 @@ async function send(
display: flex;
align-items: center;
gap: 10px;
}
.display-pick-label {
}.display-pick-label {
font-size: 12px;
color: var(--mx-t3);
white-space: nowrap;
@@ -796,6 +1023,60 @@ async function send(
height: 34px;
padding: 0 12px;
}
.live-source {
display: flex;
align-items: center;
gap: 16px;
}
.live-source-option {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--mx-t2);
cursor: pointer;
}
.live-source-option.active .live-source-text {
color: var(--mx-primary);
font-weight: 600;
}
/* 权限不足时整组控件是禁用的,光标别再做可点的样子。 */
.live-source-option.locked {
cursor: not-allowed;
}
.live-hint {
margin-top: 8px;
margin-bottom: 12px;
}
.live-picks {
display: flex;
flex-direction: column;
gap: 8px;
}
.live-pick {
display: flex;
align-items: center;
gap: 8px;
}
.live-pick-label {
font-size: 12px;
color: var(--mx-t3);
white-space: nowrap;
}
.live-pick-select {
flex: 1;
min-width: 0;
}
.live-resolved {
font-size: 12px;
color: var(--mx-t3);
min-width: 0;
overflow-wrap: anywhere;
}
.live-resolved strong {
color: var(--mx-text);
font-weight: 600;
}
.display-row {
display: flex;
align-items: center;
File diff suppressed because it is too large. Load diff
+82 -25
View File
@@ -13,7 +13,7 @@ import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
import { resultCellIssue } from './entryGuards'
import { judgeEntryRow, type EntryRowJudgement } from './entryGuards'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
@@ -146,24 +146,47 @@ function unitOf(type: Event['resultType']): string {
return ''
}
/** 发布前防呆:与录入页共用同一套文案,避免整组发布缺成绩 / 无法解析的行。 */
function rowLabel(row: DraftRow, index: number): string {
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
return `第 ${index + 1} 行 ${name}`
}
function needsRankOf(group: DraftGroup): boolean {
return group.event.resultMode === 'manual' || group.event.compare === 'none'
}
/** 单行判定与录入页共用同一套纯函数:空成绩=未录(不挡),写错/缺名次=有错(挡)。 */
function judgeRow(group: DraftGroup, row: DraftRow, index: number): EntryRowJudgement {
return judgeEntryRow({
label: rowLabel(row, index),
resultText: row.resultText,
unit: unitOf(group.event.resultType),
status: row.status,
rank: row.rank,
needsRank: needsRankOf(group),
})
}
/** 发布前防呆:与录入页共用同一套判定,只有「写错」才挡发布,空成绩只是本次跳过。 */
function publishBlockers(group: DraftGroup): string[] {
const unit = unitOf(group.event.resultType)
const manual = group.event.resultMode === 'manual' || group.event.compare === 'none'
const blockers: string[] = []
for (const [index, row] of group.rows.entries()) {
if (row.status !== 'ok') continue
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
const label = `第 ${index + 1} 行 ${name}`
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
const emptyAllowed = row.resultText.trim() === '' && manual && row.rank !== null
const issue = emptyAllowed ? null : resultCellIssue(label, row.resultText, unit)
if (issue !== null) blockers.push(issue.message)
if (manual && row.rank === null) blockers.push(`${label}:缺少名次`)
const blocker = judgeRow(group, row, index).blocker
if (blocker !== null) blockers.push(blocker)
}
return blockers
}
/** 本次发布会跳过的「还没录」行数,确认框必须说清楚只会发布已填的部分。 */
function skippedCount(group: DraftGroup): number {
return group.rows.filter((row, index) => judgeRow(group, row, index).state === 'missing').length
}
/** 本次发布真正会带上屏的行数。 */
function publishableCount(group: DraftGroup): number {
return group.rows.length - skippedCount(group)
}
function sportNameOf(event: Event): string {
return data.sports.find((sport) => sport.id === event.sportId)?.name ?? '未分组'
}
@@ -173,15 +196,16 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
const drafts = scoped.filter((entry) => entry.result?.state === 'draft')
if (drafts.length === 0) return null
const scorable: ScorableRow[] = scoped.map((entry) => ({
entryId: entry.id,
teamId: entry.teamId,
athleteId: entry.athleteId,
resultValue: entry.result?.resultValue ?? null,
rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok',
isTie: entry.result?.isTie ?? false,
}))
const scorableOf = (list: EntryWithResult[]): ScorableRow[] =>
list.map((entry) => ({
entryId: entry.id,
teamId: entry.teamId,
athleteId: entry.athleteId,
resultValue: entry.result?.resultValue ?? null,
rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok',
isTie: entry.result?.isTie ?? false,
}))
const context: ScoreContext = {
event,
category,
@@ -189,7 +213,15 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
profiles: data.profiles,
weight: weight.value,
}
const score = computeEventScore(scorable, context)
// 全量(含已发布)口径 = 本次发布后服务端会算出的结果。
const score = computeEventScore(scorableOf(scoped), context)
// 只含已发布行的一次独立算分 = 该组此刻在榜上的结果。
// 分批发布时不能拿全量口径里已发布行的分去扣:新一批会挤掉老一批的名次,
// 扣的分必须来自「只有已发布行」的那次算分,差值才是本次发布的真实影响。
const publishedScore = computeEventScore(
scorableOf(scoped.filter((entry) => entry.result?.state === 'published')),
context,
)
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
// rowTeamPoints 已按通道 scope 折算好个人→单位系数,与服务端流水口径一致。
@@ -198,7 +230,7 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
if (entry.result?.state !== 'published') continue
published.set(
entry.teamId,
(published.get(entry.teamId) ?? 0) + round(score.rowTeamPoints.get(entry.id) ?? 0),
(published.get(entry.teamId) ?? 0) + round(publishedScore.rowTeamPoints.get(entry.id) ?? 0),
)
}
@@ -313,6 +345,10 @@ function requestPublish(group: DraftGroup): void {
toast.error(`有 ${blockers.length} 处校验未通过`, blockers[0] ?? null)
return
}
if (publishableCount(group) === 0) {
toast.error('没有可发布的成绩', '这组全是未录成绩的行,补录至少一条后再发布')
return
}
publishTarget.value = group
}
@@ -513,6 +549,7 @@ watch(
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th class="center">录入</th>
<th>单位</th>
<th>运动员 / 队</th>
<th>成绩</th>
@@ -524,11 +561,29 @@ watch(
</thead>
<tbody>
<tr
v-for="row in selectedGroup.rows"
v-for="(row, index) in selectedGroup.rows"
:key="row.entryId"
:class="{ selected: row.entryId === selectedEntryId }"
@click="selectedEntryId = row.entryId"
>
<td class="center">
<span
class="mx-badge"
:class="{
'mx-badge-error': judgeRow(selectedGroup, row, index).state === 'error',
'mx-badge-warning': judgeRow(selectedGroup, row, index).state === 'missing',
'mx-badge-success': judgeRow(selectedGroup, row, index).state === 'filled',
}"
>
{{
judgeRow(selectedGroup, row, index).state === 'error'
? '有错'
: judgeRow(selectedGroup, row, index).state === 'missing'
? '未录'
: '已填'
}}
</span>
</td>
<td>
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
</td>
@@ -581,7 +636,9 @@ watch(
:message="
publishTarget === null
? ''
: `本次将影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
: skippedCount(publishTarget) > 0
? `本组还有 ${skippedCount(publishTarget)} 条没有成绩,本次将发布已填的 ${publishableCount(publishTarget)} 条;其余保持未发布,补录后再发布即可。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
: `本次将发布该组全部 ${publishableCount(publishTarget)} 条。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
"
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
confirm-text="确认发布"
+126 -11
View File
@@ -1,4 +1,4 @@
import { parseResultValue, parseSecondsDetail } from '@/shared'
import { formatPoints, parseResultValue, parseSecondsDetail } from '@/shared'
/**
* 成绩录入 / 发布前的防呆守卫(纯函数)。
@@ -13,6 +13,13 @@ export type ResultCellIssueKind = 'empty' | 'seconds-overflow' | 'unrecognized'
export interface ResultCellIssue {
kind: ResultCellIssueKind
/**
* 是否必须修好才能发布。
*
* 空成绩只是「还没跑 / 还没录」,在分批比赛的现场属于中性状态,绝不能挡住发布;
* 写错(秒位越界、写法不认识)会导致算错,必须挡住。
*/
blocking: boolean
/** 可直接展示的报错文案。 */
message: string
}
@@ -33,29 +40,137 @@ function formatHint(unit: string): string {
}
/**
* 一个成绩格是否阻止发布;通过时返回 null。
* 成绩格报错的核心文案(不含行标签)。
*
* 录入页的实时提示与发布前防呆必须共用这里:两处各写一份,迟早会出现
* 「输入时说没问题、发布时被拦下」这种自相矛盾的提示。
*/
function cellErrorMessage(text: string, unit: string): string {
if (unit === 's' && parseSecondsDetail(text).failure === 'seconds-overflow') {
return `秒位不能 ≥ 60(当前「${cellText(text)}」),请写成 1:06 或 66 这类`
}
return `成绩写法无法识别(当前「${cellText(text)}」),${formatHint(unit)}`
}
// ── 聚焦成绩格的实时提示(三档:✅解析结果 / ⚠️可疑 / 🚫不合法) ──────────
export type ResultCellHintLevel = 'ok' | 'warn' | 'error'
export interface ResultCellHint {
level: ResultCellHintLevel
/** 可直接展示的提示文案。 */
message: string
}
/** 合法成绩的展示单位;未知单位不编造单位名。 */
const UNIT_SUFFIX: Record<string, string> = { s: '秒', m: '米', 次: '次', pts: '分' }
/** 合法成绩的解析结果:让录入员确认「系统理解的和我想的是同一个数」。 */
function parsedValueMessage(value: number, unit: string): string {
const suffix = UNIT_SUFFIX[unit] ?? ''
return suffix === '' ? `= ${formatPoints(value)}` : `= ${formatPoints(value)} ${suffix}`
}
/**
* 聚焦某一格时应显示什么、什么级别;空格子返回 null(空格子是正常状态,不该弹东西)。
*
* 判定与 resultCellIssue / judgeEntryRow 完全同源:写错 = error(发布会被挡),
* 成绩 ≤ 0 = warn(放行但提个醒),能解析 = ok(把解析结果告诉录入员)。
*/
export function resultCellHint(text: string, unit: string): ResultCellHint | null {
if (text.trim() === '') return null
const value = parseResultValue(text, unit)
if (value === null) return { level: 'error', message: cellErrorMessage(text, unit) }
if (value <= 0) return { level: 'warn', message: '成绩 ≤ 0,疑似录入错误' }
return { level: 'ok', message: parsedValueMessage(value, unit) }
}
/**
* 一个成绩格的问题等级;没有问题时返回 null。
*
* 三种失败原因的文案必须互不混淆,且都带上那一格的实际内容:
* 「空的」和「填错格子」看起来一样,只写「缺少成绩」会把人引去改本来就对的格式。
*
* 只有「写错」才是 blocking;空格子是「还没录」,属于提示,不阻塞发布——
* 现场分批比赛时(跑道 8 条、人数 30),要能先把跑完的这一批发出去。
*/
export function resultCellIssue(label: string, text: string, unit: string): ResultCellIssue | null {
if (text.trim() === '') {
return { kind: 'empty', message: `${label}:成绩为空(当前「${cellText(text)}」),请填写成绩` }
return {
kind: 'empty',
blocking: false,
message: `${label}:还没录成绩(当前「${cellText(text)}」),本次发布不计入这一行`,
}
}
if (parseResultValue(text, unit) !== null) return null
if (unit === 's' && parseSecondsDetail(text).failure === 'seconds-overflow') {
return {
kind: 'seconds-overflow',
message: `${label}:秒位不能 ≥ 60(当前「${cellText(text)}」),请写成 1:06 或 66 这类`,
}
}
const overflow = unit === 's' && parseSecondsDetail(text).failure === 'seconds-overflow'
return {
kind: 'unrecognized',
message: `${label}:成绩写法无法识别(当前「${cellText(text)}」),${formatHint(unit)}`,
kind: overflow ? 'seconds-overflow' : 'unrecognized',
blocking: true,
message: `${label}:${cellErrorMessage(text, unit)}`,
}
}
// ── 单行录入状态(✅已填 / ⚠️未录 / 🚫有错) ────────────────────────────────
/** 一行报名项在录入页上的状态:已填 / 未录(中性,不阻塞发布)/ 有错(阻塞发布)。 */
export type EntryRowState = 'filled' | 'missing' | 'error'
export interface EntryRowJudgement {
state: EntryRowState
/** 阻止发布的原因;只有 state=error 时非空。 */
blocker: string | null
/** 只提示、不阻塞的原因(未录、成绩 ≤ 0 之类的疑似错误)。 */
warning: string | null
}
export interface EntryRowInput {
/** 行标签,如「第 3 行 张三」。 */
label: string
resultText: string
unit: string
/** 成绩状态(ok / dns / dq / dnf / withdrawn)。 */
status: string
rank: number | null
/** 该项目是否以手工名次为成绩(manual / compare=none)。 */
needsRank: boolean
}
/**
* 判断一行是否可发布,并给出徽标状态。
*
* 三类互斥:
* - 🚫 有错:写法无法识别、秒位越界、手动项目缺名次 —— 会算错,必须挡住;
* - ⚠️ 未录:成绩为空(手动项目已填名次的除外)—— 还没跑完,跳过即可;
* - ✅ 已填:其余(含 DNS / DQ / DNF / 退赛 这类没有数值但确实录了的现场结果)。
*/
export function judgeEntryRow(input: EntryRowInput): EntryRowJudgement {
const text = input.resultText.trim()
const filled = (warning: string | null): EntryRowJudgement => ({ state: 'filled', blocker: null, warning })
// 非 ok 状态是操作员明确录入的比赛结果,即使没有成绩数值也算「已填」。
if (input.status !== 'ok') return filled(null)
// 手动名次项目:名次本身就是成绩,缺名次才是错误;只填了名次、没填文本是允许的。
if (input.needsRank && input.rank === null) {
return { state: 'error', blocker: `${input.label}:缺少名次`, warning: null }
}
if (text === '') {
if (input.needsRank) return filled(null)
const empty = resultCellIssue(input.label, input.resultText, input.unit)
return { state: 'missing', blocker: null, warning: empty?.message ?? null }
}
const cellIssue = resultCellIssue(input.label, input.resultText, input.unit)
if (cellIssue !== null && cellIssue.blocking) {
return { state: 'error', blocker: cellIssue.message, warning: null }
}
const value = parseResultValue(input.resultText, input.unit)
if (value !== null && value <= 0) return filled(`${input.label}:成绩 ≤ 0,疑似录入错误`)
return filled(null)
}
// ── 未保存修改的离开守卫 ────────────────────────────────────────────────────
export const DISCARD_CONFIRM_TITLE = '有未保存的成绩修改'
+123
View File
@@ -0,0 +1,123 @@
/**
* 批次(heat)相关的纯函数:下拉选项推导、按批次筛选、按人数切片分配批次号。
*
* 抽出来的原因:`.vue` 组件跑不进 `node --test`,而「一键均分」的两条现场规则
* ——已分批的行一个都不动、新批次号接着最大号往后排——最容易写错也最难在界面上核对。
*
* 批次只是录入 / 展示分组,这里算出来的任何东西都不参与名次与积分。
*/
/** 批次筛选值:'all' = 全部,'none' = 未分批,正整数 = 第 N 批。 */
export type HeatFilter = number | 'all' | 'none'
/** 批次号上限与服务端 maxEntryHeat 保持一致,超出会被接口拒绝。 */
export const MAX_HEAT = 999
/** 一键均分的默认每批人数:一条跑道 8 道,正好一批跑完。 */
export const DEFAULT_HEAT_SIZE = 8
export interface HeatOption {
value: HeatFilter
label: string
}
/** 只要求带批次号,便于表格行与测试夹具复用。 */
export interface HeatRow {
heat: number | null
}
export interface HeatEntryRow extends HeatRow {
entryId: number
}
/** 批次展示文案:null = 未分批。 */
export function heatLabel(heat: number | null): string {
return heat === null ? '未分批' : `第 ${heat} 批`
}
/**
* 批次下拉选项:全部 → 当前组别已有的批次(去重升序)→ 未分批。
*
* 「未分批」只在真的还有未分批的行时才给,否则选中它只会得到一张空表。
*/
export function heatOptions(rows: readonly HeatRow[]): HeatOption[] {
const options: HeatOption[] = [{ value: 'all', label: '全部' }]
const heats = [...new Set(rows.map((row) => row.heat).filter((heat): heat is number => heat !== null))].sort(
(a, b) => a - b,
)
for (const heat of heats) options.push({ value: heat, label: heatLabel(heat) })
if (rows.some((row) => row.heat === null)) options.push({ value: 'none', label: heatLabel(null) })
return options
}
/** 按批次筛选表格行;顺序保持传入顺序(即表格显示顺序)。 */
export function filterRowsByHeat<T extends HeatRow>(rows: readonly T[], filter: HeatFilter): T[] {
if (filter === 'all') return [...rows]
if (filter === 'none') return rows.filter((row) => row.heat === null)
return rows.filter((row) => row.heat === filter)
}
/** 当前最大的已有批次号;一个批次都没有时返回 0,新批次就从 1 开始。 */
export function maxHeat(rows: readonly HeatRow[]): number {
return rows.reduce((max, row) => (row.heat !== null && row.heat > max ? row.heat : max), 0)
}
/** 一批要下发的批次号与报名项;heat 为 null 表示清除为「未分批」。 */
export interface HeatAllocation {
heat: number | null
entryIds: number[]
}
/** 「一键均分」的编排结果,同时供确认框展示与提交使用。 */
export interface HeatSplitPlan {
/** 参与编排的未分批行数。 */
rowCount: number
/** 每批人数。 */
perHeat: number
batchCount: number
/** 第一个新批次号(已有最大号 + 1)。 */
startHeat: number
allocations: HeatAllocation[]
}
/**
* 一键均分:按传入顺序(= 当前表格显示顺序)把未分批的行每 perHeat 人切一批,
* 批次号接着已有最大批次号往后排。已经分批的行不参与,也因此不会被冲掉。
*
* perHeat 不是正整数、或没有未分批的行时返回 null,调用方据此提示并停用按钮。
*/
export function planHeatSplit(rows: readonly HeatEntryRow[], perHeat: number): HeatSplitPlan | null {
if (!Number.isInteger(perHeat) || perHeat < 1) return null
const unbatched = rows.filter((row) => row.heat === null)
if (unbatched.length === 0) return null
const startHeat = maxHeat(rows) + 1
const allocations: HeatAllocation[] = []
for (let i = 0; i < unbatched.length; i += perHeat) {
allocations.push({
heat: startHeat + allocations.length,
entryIds: unbatched.slice(i, i + perHeat).map((row) => row.entryId),
})
}
return {
rowCount: unbatched.length,
perHeat,
batchCount: allocations.length,
startHeat,
allocations,
}
}
/**
* 批量设置成功后同步本地行:只改命中的 entryId,其余行(含正在编辑的成绩)原样保留。
*
* 录入页不能因为改批次就整表重载——那样会把还没保存的成绩草稿一起丢掉。
*/
export function applyHeatToRows<T extends HeatEntryRow>(
rows: readonly T[],
entryIds: readonly number[],
heat: number | null,
): T[] {
const target = new Set(entryIds)
return rows.map((row) => (target.has(row.entryId) ? { ...row, heat } : row))
}
+82
View File
@@ -66,6 +66,12 @@ body {
transition:
border-color var(--mx-d1) var(--mx-ease),
box-shadow var(--mx-d1) var(--mx-ease);
/*
* 表格是 table-layout:auto,width:100% 对列的 min-content 没有贡献;
* 窄屏下这些格子会被压到二十几像素,连一个字都看不到。
* 给一个下限,让宽表撑出横向滚动而不是把输入框压扁。
*/
min-width: 80px;
}
.mx-cell-input:focus {
border-color: var(--mx-primary);
@@ -96,3 +102,79 @@ body {
.mx-table-entry tbody tr.is-focus {
background: var(--mx-primary-glow);
}
/*
* 窄屏表格:回退成「表头 + 横向滚动」,不用 vendor 的卡片化。
*
* vendor 的 @media(max-width:767px) 卡片模式要求每个 td 带 data-label(::before 靠它
* 显示字段名)才成立;管理端所有表格都没有这个属性,套上去的结果是表头消失、
* 每格只留一个无名值,而且同一段规则还把 .mx-table-wrap 的 overflow-x 改成了 visible,
* 宽表(运动员、日志、录入)会直接顶破卡片和整页。现场手机端更需要"能横向滑着看",
* 所以这里在管理端范围内把表格恢复成常规表格并保留横向滚动。
*/
@media (max-width: 767px) {
.admin-shell .mx-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-shell .mx-table thead {
display: table-header-group;
}
.admin-shell .mx-table tbody {
display: table-row-group;
}
.admin-shell .mx-table tbody tr {
display: table-row;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.admin-shell .mx-table tbody td {
display: table-cell;
padding: 8px 10px;
border-bottom: 1px solid var(--mx-divider);
white-space: nowrap;
}
.admin-shell .mx-table tbody td::before {
content: none;
}
.admin-shell .mx-table tbody tr:hover td {
padding-left: 10px;
}
/* 手机端表格内输入框触控目标补齐到 ~40px(vendor 的 ≤767 规则停在 34px)。 */
.admin-shell .mx-cell-input {
height: 40px;
}
/*
* 卡片头在窄屏允许换行:像「破纪录」页把标题与一排筛选页签放同一行时,
* 页签作为 flex 子项的 min-width:auto 会按 min-content 撑开(320px 尤其明显),
* 把卡片头顶出屏幕。允许换行 + 让页签容器可以收缩,标题与筛选都还能用。
*/
.admin-shell .mx-card-head {
flex-wrap: wrap;
gap: 8px;
}
.admin-shell .mx-card-head .mx-tabs {
min-width: 0;
}
}
/*
* 分页按钮 / 弹窗关闭按钮:vendor 在窄屏把分页压到 32px、关闭按钮 28px,
* 现场用手机或平板点这些很容易点空。1024 以下统一补到 40px。
*/
@media (max-width: 1023px) {
.admin-shell .mx-page-btn,
.admin-shell .mx-page-ellipsis {
width: 40px;
height: 40px;
}
.admin-shell .mx-modal-close {
width: 40px;
height: 40px;
}
}
@@ -133,3 +133,42 @@ test('后端连不上时实例列表给空数组,不抛错', async () => {
assert.deepEqual(await adminApi.displayInstances(), [])
})
// 「实时赛况」当前项目由服务端判定,前端只消费结论:source / eventIds / events 原样透传。
test('实时赛况接口透传判定结果与入选原因', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
source: 'auto',
eventIds: [7],
events: [
{
event: { id: 7, name: '100米跑' },
reason: 'recent',
groups: [{ eventId: 7, categoryId: 1, categoryName: '男子', rows: [] }],
},
],
},
}),
)
const live = await adminApi.displayLive()
assert.equal(lastRequest.url, '/api/display/live')
assert.equal(lastRequest.method, 'GET')
assert.equal(live.source, 'auto')
assert.deepEqual(live.eventIds, [7])
assert.equal(live.events[0]?.event.name, '100米跑')
assert.equal(live.events[0]?.reason, 'recent')
assert.equal(live.events[0]?.groups.length, 1)
})
// 判定依赖排期与最近发布,示例数据里没有这套时序,连不上时给空结论而不是抛错。
test('后端连不上时实时赛况给空结论', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
assert.deepEqual(await adminApi.displayLive(), { source: 'auto', eventIds: [], events: [] })
})
+65 -3
View File
@@ -1,13 +1,20 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createLeaveGuard, resultCellIssue } from '../pages/entryGuards'
import { createLeaveGuard, judgeEntryRow, resultCellIssue } from '../pages/entryGuards'
// 用户实机反馈:只写「缺少成绩」会让人去改本来就对的成绩格式,
// 文案必须带上那一格的实际内容,并区分「空的 / 秒位越界 / 写法不认识」。
test('空格子:文案说明成绩为空并带出「(空)」', () => {
// 现场分批比赛后,空格子只是「还没录」,归为提示、不阻塞发布。
test('空格子:文案说明还没录并带出「(空)」,且不阻塞发布', () => {
const issue = resultCellIssue('第 3 行 张三', ' ', 's')
assert.equal(issue?.kind, 'empty')
assert.equal(issue?.message, '第 3 行 张三:成绩为空(当前「(空)」),请填写成绩')
assert.equal(issue?.blocking, false)
assert.equal(issue?.message, '第 3 行 张三:还没录成绩(当前「(空)」),本次发布不计入这一行')
})
test('写错的成绩阻塞发布:秒位越界与写法不认识都是 blocking', () => {
assert.equal(resultCellIssue('第 3 行 张三', '1:70', 's')?.blocking, true)
assert.equal(resultCellIssue('第 3 行 张三', '约1分06秒', 's')?.blocking, true)
})
test('秒位 ≥ 60:文案带出原内容和可用的写法', () => {
@@ -53,6 +60,61 @@ test('距离 / 计数项目的文案不套用时间格式提示', () => {
assert.ok(!issue?.message.includes('1:06'), '不该拿时间格式去教距离项目')
})
// 三类行徽标:已填 / 未录 / 有错。空成绩不再算错误,但要有提示。
test('行状态三类互斥:空成绩=未录(提示),写错=有错(阻塞)', () => {
const missing = judgeEntryRow({
label: '第 22 行 赵六', resultText: ' ', unit: 's', status: 'ok', rank: null, needsRank: false,
})
assert.equal(missing.state, 'missing')
assert.equal(missing.blocker, null)
assert.ok(missing.warning?.includes('还没录成绩'), missing.warning ?? '')
const error = judgeEntryRow({
label: '第 3 行 张三', resultText: '1:70', unit: 's', status: 'ok', rank: null, needsRank: false,
})
assert.equal(error.state, 'error')
assert.ok(error.blocker?.includes('秒位不能 ≥ 60'), error.blocker ?? '')
const filled = judgeEntryRow({
label: '第 1 行 李四', resultText: '66.33', unit: 's', status: 'ok', rank: null, needsRank: false,
})
assert.equal(filled.state, 'filled')
assert.equal(filled.blocker, null)
})
test('非 ok 状态(DNS/DQ/DNF/退赛)没有数值也算已填', () => {
for (const status of ['dns', 'dq', 'dnf', 'withdrawn']) {
const row = judgeEntryRow({
label: '第 2 行 王五', resultText: '', unit: 's', status, rank: null, needsRank: false,
})
assert.equal(row.state, 'filled', status)
assert.equal(row.warning, null)
}
})
test('手动名次项目:只填名次即可发布,缺名次才阻塞', () => {
const rankOnly = judgeEntryRow({
label: '第 4 行 篮球', resultText: '', unit: '', status: 'ok', rank: 1, needsRank: true,
})
assert.equal(rankOnly.state, 'filled')
assert.equal(rankOnly.blocker, null)
const noRank = judgeEntryRow({
label: '第 4 行 篮球', resultText: '', unit: '', status: 'ok', rank: null, needsRank: true,
})
assert.equal(noRank.state, 'error')
assert.ok(noRank.blocker?.includes('缺少名次'), noRank.blocker ?? '')
})
test('成绩 ≤ 0 只提示不挡:状态仍是已填', () => {
const row = judgeEntryRow({
label: '第 5 行 田径', resultText: '0', unit: 's', status: 'ok', rank: null, needsRank: false,
})
assert.equal(row.state, 'filled')
assert.equal(row.blocker, null)
assert.ok(row.warning?.includes('≤ 0'), row.warning ?? '')
})
// 路由离开守卫:dirty 为 true 时必须拦下,确认后重放那一次必须放行(否则死循环)。
test('未保存时离开被拦下,确认后重放放行', () => {
let dirty = true
@@ -0,0 +1,49 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createApiClient } from '@/shared'
/** 真打一遍客户端的请求构造:路径、方法、请求体形状必须与服务端契约一致。 */
test('setHeat 走 PUT /entries/heat 并带上 entryIds + heat', async () => {
const captured: { url?: string; method?: string; body?: unknown } = {}
const client = createApiClient({
baseUrl: '/api',
requestId: () => 'req-fixed',
fetchImpl: async (input, init) => {
captured.url = String(input)
captured.method = init?.method
captured.body = init?.body
return new Response(
JSON.stringify({ ok: true, data: { updated: 3, heat: 2, entryIds: [4, 5, 6] } }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
},
})
const result = await client.entries.setHeat({ entryIds: [4, 5, 6], heat: 2 })
assert.equal(captured.url, '/api/entries/heat')
assert.equal(captured.method, 'PUT')
// 写操作由客户端补 requestId(幂等重放),其余字段原样。
assert.equal(captured.body, JSON.stringify({ entryIds: [4, 5, 6], heat: 2, requestId: 'req-fixed' }))
assert.deepEqual(result, { updated: 3, heat: 2, entryIds: [4, 5, 6] })
})
test('setHeat 传 null 表示清除批次', async () => {
const captured: { body?: unknown } = {}
const client = createApiClient({
baseUrl: '/api',
requestId: () => 'req-fixed',
fetchImpl: async (_input, init) => {
captured.body = init?.body
return new Response(
JSON.stringify({ ok: true, data: { updated: 2, heat: null, entryIds: [1, 2] } }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
},
})
const result = await client.entries.setHeat({ entryIds: [1, 2], heat: null })
assert.equal(captured.body, JSON.stringify({ entryIds: [1, 2], heat: null, requestId: 'req-fixed' }))
assert.equal(result.heat, null)
})
+128
View File
@@ -0,0 +1,128 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
applyHeatToRows,
filterRowsByHeat,
heatLabel,
heatOptions,
maxHeat,
planHeatSplit,
type HeatEntryRow,
} from '../pages/entryHeat'
type Row = HeatEntryRow & { name: string }
function rows(spec: Array<[string, number | null]>): Row[] {
return spec.map(([name, heat], index) => ({ entryId: index + 1, name, heat }))
}
test('heatLabel:未分批与第 N 批', () => {
assert.equal(heatLabel(null), '未分批')
assert.equal(heatLabel(1), '第 1 批')
assert.equal(heatLabel(12), '第 12 批')
})
test('heatOptions:全部 → 已有批次去重升序 → 未分批', () => {
const options = heatOptions(rows([['甲', 2], ['乙', null], ['丙', 1], ['丁', 2]]))
assert.deepEqual(options, [
{ value: 'all', label: '全部' },
{ value: 1, label: '第 1 批' },
{ value: 2, label: '第 2 批' },
{ value: 'none', label: '未分批' },
])
})
test('heatOptions:没有未分批的行时不给「未分批」选项,避免选中得到空表', () => {
assert.deepEqual(heatOptions(rows([['甲', 1], ['乙', 3]])), [
{ value: 'all', label: '全部' },
{ value: 1, label: '第 1 批' },
{ value: 3, label: '第 3 批' },
])
})
test('heatOptions:一条报名都没有时只有「全部」', () => {
assert.deepEqual(heatOptions([]), [{ value: 'all', label: '全部' }])
})
test('filterRowsByHeat:全部 / 指定批次 / 未分批,顺序不变', () => {
const list = rows([['甲', 1], ['乙', null], ['丙', 2], ['丁', 1]])
assert.deepEqual(
filterRowsByHeat(list, 'all').map((row) => row.name),
['甲', '乙', '丙', '丁'],
)
assert.deepEqual(
filterRowsByHeat(list, 1).map((row) => row.name),
['甲', '丁'],
)
assert.deepEqual(
filterRowsByHeat(list, 'none').map((row) => row.name),
['乙'],
)
assert.deepEqual(filterRowsByHeat(list, 9), [])
// 返回新数组,不改动入参。
assert.equal(list.length, 4)
})
test('maxHeat:取已有最大批次号,没有批次时是 0', () => {
assert.equal(maxHeat([]), 0)
assert.equal(maxHeat(rows([['甲', null], ['乙', null]])), 0)
assert.equal(maxHeat(rows([['甲', 2], ['乙', null], ['丙', 5]])), 5)
})
test('planHeatSplit:未分批的行按每批人数顺序切片,批次号从 1 开始', () => {
const list = rows([
['甲', null], ['乙', null], ['丙', null], ['丁', null], ['戊', null],
['己', null], ['庚', null], ['辛', null], ['壬', null], ['癸', null],
])
const plan = planHeatSplit(list, 8)
assert.ok(plan !== null)
assert.equal(plan.rowCount, 10)
assert.equal(plan.batchCount, 2)
assert.equal(plan.startHeat, 1)
assert.equal(plan.perHeat, 8)
assert.deepEqual(plan.allocations, [
{ heat: 1, entryIds: [1, 2, 3, 4, 5, 6, 7, 8] },
{ heat: 2, entryIds: [9, 10] },
])
})
test('planHeatSplit:已经分批的行不动,新批次号接着最大号往后排', () => {
const list = rows([
['甲', 3], ['乙', null], ['丙', null], ['丁', 1], ['戊', null],
])
const plan = planHeatSplit(list, 2)
assert.ok(plan !== null)
assert.equal(plan.startHeat, 4, '接着最大批次号 3 往后排,而不是从 1 覆盖')
assert.deepEqual(plan.allocations, [
{ heat: 4, entryIds: [2, 3] },
{ heat: 5, entryIds: [5] },
])
// 已分批的甲 / 丁不在任何一批里。
const touched = plan.allocations.flatMap((item) => item.entryIds)
assert.equal(touched.includes(1), false)
assert.equal(touched.includes(4), false)
})
test('planHeatSplit:每批人数大于剩余人数时只出一批', () => {
const plan = planHeatSplit(rows([['甲', null], ['乙', null], ['丙', null]]), 8)
assert.deepEqual(plan?.allocations, [{ heat: 1, entryIds: [1, 2, 3] }])
})
test('planHeatSplit:没有未分批的行、或每批人数非法时返回 null', () => {
assert.equal(planHeatSplit(rows([['甲', 1]]), 8), null, '全都分过批就不该有编排')
assert.equal(planHeatSplit(rows([['甲', null]]), 0), null)
assert.equal(planHeatSplit(rows([['甲', null]]), -3), null)
assert.equal(planHeatSplit(rows([['甲', null]]), 2.5), null)
})
test('applyHeatToRows:只改命中的行,其余行(含正在编辑的成绩)原样保留', () => {
const list = rows([['甲', null], ['乙', 1], ['丙', null]])
const next = applyHeatToRows(list, [1, 3], 7)
assert.deepEqual(next.map((row) => row.heat), [7, 1, 7])
assert.deepEqual(list.map((row) => row.heat), [null, 1, null], '不能改到入参')
const cleared = applyHeatToRows(next, [1], null)
assert.deepEqual(cleared.map((row) => row.heat), [null, 1, 7])
// 未命中的批次筛选值不受影响。
assert.deepEqual(applyHeatToRows(list, [], 5).map((row) => row.heat), [null, 1, null])
})
+66
View File
@@ -0,0 +1,66 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { resultCellHint, resultCellIssue } from '../pages/entryGuards'
// 聚焦成绩格的实时提示:三档(✅解析结果 / ⚠️可疑 / 🚫不合法)+ 空格子不弹。
// 文案与 resultCellIssue 同源,避免「输入时说没问题、发布时被拦下」这种自相矛盾的提示。
test('合法时间:把解析结果显示出来(1:06 → = 66 秒)', () => {
const hint = resultCellHint('1:06', 's')
assert.equal(hint?.level, 'ok')
assert.equal(hint?.message, '= 66 秒')
})
test('合法小数与全角写法也按秒数显示', () => {
assert.equal(resultCellHint('66.33', 's')?.message, '= 66.33 秒')
assert.equal(resultCellHint('1:06.33', 's')?.message, '= 66.33 秒')
assert.equal(resultCellHint('1:06', 's')?.message, '= 66 秒')
})
test('非时间单位:显示对应数值与单位', () => {
assert.equal(resultCellHint('12.34', 'm')?.message, '= 12.34 米')
assert.equal(resultCellHint('12', '次')?.message, '= 12 次')
assert.equal(resultCellHint('96.5', 'pts')?.message, '= 96.5 分')
})
test('空成绩不弹提示', () => {
assert.equal(resultCellHint('', 's'), null)
assert.equal(resultCellHint(' ', 's'), null)
})
test('成绩 ≤ 0:可疑(warn),不阻塞但提醒疑似错误', () => {
const hint = resultCellHint('0', 's')
assert.equal(hint?.level, 'warn')
assert.equal(hint?.message, '成绩 ≤ 0,疑似录入错误')
})
test('秒位 ≥ 60:不合法(error),带出那一格的原文', () => {
const hint = resultCellHint('1:70', 's')
assert.equal(hint?.level, 'error')
assert.ok(hint?.message.includes('秒位不能 ≥ 60'), hint?.message ?? '')
assert.ok(hint?.message.includes('「1:70」'), hint?.message ?? '')
// 边界:恰好 60 也算越界(60 秒要写成 1:00)。
assert.equal(resultCellHint('1:60', 's')?.level, 'error')
})
test('写法不认识:不合法(error),带出原文与可填写法', () => {
const hint = resultCellHint('约1分06秒', 's')
assert.equal(hint?.level, 'error')
assert.ok(hint?.message.includes('成绩写法无法识别'), hint?.message ?? '')
assert.ok(hint?.message.includes('「约1分06秒」'), hint?.message ?? '')
})
test('非时间单位不套用时间格式提示', () => {
const hint = resultCellHint('1.2.3', 'm')
assert.equal(hint?.level, 'error')
assert.ok(hint?.message.includes('米'), hint?.message ?? '')
assert.ok(!hint?.message.includes('1:06'), '不该拿时间格式去教距离项目')
})
test('实时提示与发布前防呆同源:错误文案去掉行标签后完全一致', () => {
for (const text of ['1:70', '约1分06秒', '1.2.3', '快点跑']) {
const hint = resultCellHint(text, 's')
const issue = resultCellIssue('第 3 行 张三', text, 's')
assert.equal(hint?.message, issue?.message.replace('第 3 行 张三:', ''), text)
}
})
+23 -21
View File
@@ -29,22 +29,24 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
})
test('表头含证件号 / 性别 / 年龄可选列,必填列不含这三列', () => {
test('表头含证件号 / 性别 / 年龄 / 批次可选列,必填列不含这四列', () => {
assert.deepEqual(
[...REQUIRED_COLUMNS],
[
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
'bib', 'seed', 'status', 'member_names',
'bib', 'seed', 'heat', 'status', 'member_names',
],
)
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age'])
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('seed') + 1], 'heat', 'heat 紧跟在 seed 之后')
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat'])
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
assert.equal(MANDATORY_COLUMNS.includes('age'), false)
assert.equal(MANDATORY_COLUMNS.includes('heat'), false, '批次列可缺省,缺省即未分批')
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
})
test('buildSelectionRows 个人项目:一人一行,共 11 列并带上证件号 / 号码布', () => {
test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件号 / 号码布', () => {
const rows = buildSelectionRows({
eventCode: 'M100',
categoryName: '男子组',
@@ -58,13 +60,13 @@ test('buildSelectionRows 个人项目:一人一行,共 11 列并带上证件
})
assert.equal(rows.length, 4, '表头 + 3 名运动员')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 11)
// 勾选报名的人员已在库里,年龄列留空表示「不修改」。
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', 'registered', ''])
assert.equal(rows[1]?.length, 12)
// 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排)。
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', ''])
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', 'registered', ''])
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', ''])
// 只有证件号时 bib 列留空。
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', 'registered', ''])
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', ''])
})
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
@@ -76,8 +78,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
})
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
assert.equal(rows[1]?.length, 11)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
assert.equal(rows[1]?.length, 12)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
})
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
@@ -90,7 +92,7 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
athletes: [{ name: '王芳' }],
memberNames: ['刘洋', '王芳'],
})
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
})
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
@@ -101,7 +103,7 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列
mode: 'individual',
athletes: [{ name: '无标识' }],
})
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', 'registered', ''])
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', ''])
})
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
@@ -114,7 +116,7 @@ test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', ()
athletes: [],
})
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
})
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
@@ -137,13 +139,13 @@ test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,
})
assert.equal(rows.length, 4, '表头 + 3 个单位')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 11)
assert.equal(rows[1]?.length, 12)
for (const row of rows.slice(1)) {
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
}
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', 'registered', '张伟'])
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟'])
})
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
@@ -155,7 +157,7 @@ test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可
status: 'scratched',
teams: [{ teamCode: 'F1', memberNames: [] }],
})
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', 'scratched', ''])
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', ''])
})
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
@@ -273,8 +275,8 @@ test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行',
const lines = toCsv(rows).split('\n')
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
assert.equal(lines.length, 3, '表头 + 两个单位')
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,registered,', '只报单位时 member_names 留空')
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,', '只报单位时 member_names 留空')
})
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
+2 -2
View File
@@ -30,9 +30,9 @@ function csv(rows: string[][]): File {
return new File([toCsv([[...REQUIRED_COLUMNS], ...rows])], 'import.csv', { type: 'text/csv' })
}
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / status / member_names */
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / heat / status / member_names */
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', 'registered', '']
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
}
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
+2
View File
@@ -14,6 +14,7 @@ import WindowedTitleBar from './components/WindowedTitleBar.vue'
import AnnouncementPage from './components/pages/AnnouncementPage.vue'
import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue'
import EventResultsPage from './components/pages/EventResultsPage.vue'
import LivePage from './components/pages/LivePage.vue'
import StandingsPage from './components/pages/StandingsPage.vue'
import TeamMedalsPage from './components/pages/TeamMedalsPage.vue'
@@ -22,6 +23,7 @@ const PAGES: Record<DisplayPageKey, Component> = {
'team-medals': TeamMedalsPage,
'athlete-standings': AthleteStandingsPage,
'event-results': EventResultsPage,
live: LivePage,
announcement: AnnouncementPage,
}
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatResultText } from '@/shared'
import type { EventResultRow, ResultStatus } from '@/shared'
import { displayName, displayResult } from '../../results'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue'
@@ -10,26 +9,6 @@ const { latestResults, options } = useScoreboard()
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount))
const STATUS_LABELS: Record<ResultStatus, string> = {
ok: '',
dq: 'DQ',
dns: 'DNS',
dnf: 'DNF',
withdrawn: '退赛',
}
function displayResult(row: EventResultRow): string {
if (row.status !== 'ok') return STATUS_LABELS[row.status]
if (row.resultValue === null) return row.resultText
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
}
function displayName(row: EventResultRow): string {
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
if (row.memberNames !== null) return row.memberNames.join(' / ')
return row.shortName ?? row.teamName
}
</script>
<template>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
import { LIVE_REFRESH_INTERVAL_MS, fallbackLiveSources, pickLiveColumns } from '../../live'
import type { LiveEventSource } from '../../live'
import { displayName, displayResult } from '../../results'
import { useScoreboard } from '../../useScoreboard'
import PageFrame from '../PageFrame.vue'
import TeamLogo from '../TeamLogo.vue'
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents } = useScoreboard()
const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name])))
/**
* 「当前项目」由服务端判定(GET /api/display/live:人工指定 → 排期命中 → 最近发布)。
*
* 拉到过结论就用它(不受快照 20 行截断,auto/manual 都一样);还没成功过(老服务端、断网首屏、
* 示例数据)才退回「人工指定 + 快照分组」的兜底素材,有总比没有强。
*/
const sources = computed<LiveEventSource[]>(() =>
liveFeed.value !== null
? liveFeed.value.events
: fallbackLiveSources(
settings.value?.display.liveEvents ?? [],
latestResults.value,
eventNames.value,
),
)
const boards = computed(() => pickLiveColumns(sources.value))
const hint = computed(() => {
if (boards.value.length === 0) return ''
const base = `进行中 ${boards.value.length} 项`
// 不显眼的降级标记:拿不到新结论时画面照旧,只在标题旁说明数据可能不是最新。
return liveStale.value ? `${base} · 刷新中断` : base
})
// 还没成功拉到过又没失败 = 第一次请求还在飞,别用「暂无项目」误导现场。
const emptyText = computed(() =>
liveFeed.value === null && !liveStale.value ? '正在获取当前项目…' : '暂无进行中的项目',
)
// 字号取行数最多的那一栏,两栏行高才一致;两栏时每栏只有半屏宽,再收一档。
const rowFont = computed(() => {
const count = boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0)
return fitRowFont(count, BOARD_AVAILABLE_PX, boards.value.length > 1 ? 0.48 : 0.62)
})
// 定时兜底:项目切换靠 scheduled_at 自己过期,快照不会为时间流逝推送,所以必须有这条轮询。
// 只在本页显示时跑(锁在别的页上时没必要打服务端);切回本页时挂载会立刻拉一次。
let pollTimer = 0
onMounted(() => {
void refreshLiveEvents()
pollTimer = window.setInterval(() => {
void refreshLiveEvents()
}, LIVE_REFRESH_INTERVAL_MS)
})
onUnmounted(() => {
window.clearInterval(pollTimer)
})
</script>
<template>
<PageFrame title="实时赛况" :hint="hint">
<div
v-if="boards.length > 0"
class="boards"
:class="`boards-${boards.length}`"
:style="{ '--row-font': rowFont }"
>
<article v-for="board in boards" :key="board.eventId" class="board">
<header class="board-head">
<h3>{{ board.title }}</h3>
<span v-if="board.category" class="cat">{{ board.category }}</span>
<span v-if="board.hiddenGroups > 0" class="more">另有 {{ board.hiddenGroups }} 组</span>
</header>
<div v-if="board.rows.length > 0" class="rows">
<div class="row head">
<span class="rk">名次</span>
<span class="nm">运动员 / 队</span>
<span class="team">单位</span>
<span class="rs">成绩</span>
</div>
<div
v-for="row in board.rows"
:key="row.entryId"
class="row"
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span>
<span class="team">
<TeamLogo :path="row.logoPath" />
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
</span>
<span class="rs">{{ displayResult(row) }}</span>
</div>
</div>
<div v-else class="board-empty">该项目暂无已发布的成绩</div>
</article>
</div>
<div v-else class="empty">{{ emptyText }}</div>
</PageFrame>
</template>
<style scoped>
.boards {
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
gap: 26px;
}
.board {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.035);
border: 1px solid var(--primary-line);
border-radius: 14px;
padding: 18px 20px;
overflow: hidden;
}
/* 只有一个项目时不必铺满 1920(一行字太长反而难读),居中留白与分项成绩页一致。 */
.boards-1 .board {
max-width: 1180px;
}
.board-head {
display: flex;
align-items: baseline;
gap: 12px;
padding-bottom: 12px;
border-bottom: 2px solid var(--primary-line);
}
h3 {
font-size: 34px;
letter-spacing: 3px;
color: var(--primary-light);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cat {
flex: none;
font-size: 22px;
color: var(--text-dim);
}
.more {
flex: none;
font-size: 20px;
color: var(--text-dim);
}
.rows {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-top: 8px;
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 90px 1fr 300px 220px;
align-items: center;
gap: 12px;
flex: 1 1 0;
min-height: 0;
padding: 0 6px;
font-size: var(--row-font, 34px);
line-height: 1.1;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
animation: row-in 0.45s ease-out both;
}
.boards-2 .row {
grid-template-columns: 60px 1fr 190px 150px;
gap: 8px;
}
.row:last-child {
border-bottom: none;
}
.head {
flex: none;
font-size: 22px;
letter-spacing: 4px;
color: var(--text-dim);
border-bottom: 2px solid var(--primary-line);
}
.rk {
font-weight: 800;
text-align: center;
font-variant-numeric: tabular-nums;
}
.top-1 .rk {
color: var(--gold);
}
.top-2 .rk {
color: #d8e2ee;
}
.top-3 .rk {
color: #e0995e;
}
.nm {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team {
display: flex;
align-items: center;
gap: 0.28em;
min-width: 0;
overflow: hidden;
font-size: 0.78em;
color: var(--text-dim);
}
.team-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rs {
text-align: right;
color: var(--gold);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.board-empty,
.empty {
flex: 1;
display: grid;
place-items: center;
font-size: 34px;
color: var(--text-dim);
}
@keyframes row-in {
from {
opacity: 0;
transform: translateX(-40px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>
+106 -3
View File
@@ -25,6 +25,7 @@ import type {
Team,
TeamBreakdown,
} from '@/shared'
import type { LiveEventSource } from './live'
/**
* WebSocket 与 localStorage 里的东西一律先当 unknown 再收窄。
@@ -89,6 +90,7 @@ const GENDERS = ['男', '女'] as const satisfies readonly Gender[]
const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[]
const RECORD_SOURCES = ['history', 'current'] as const satisfies readonly RecordSource[]
const LEDGER_SOURCES = ['result', 'record_bonus', 'adjustment'] as const satisfies readonly LedgerSourceType[]
const LIVE_SOURCES = ['manual', 'auto'] as const satisfies readonly LiveSource[]
function record(source: unknown): Record<string, unknown> {
return isRecord(source) ? source : {}
@@ -321,11 +323,77 @@ export function parseRecordEntry(source: unknown): RecordEntry {
}
}
/** 「实时赛况」页当前项目的来源:manual = 人工指定,auto = 服务端按时段 / 最近发布自动判定。 */
export type LiveSource = 'manual' | 'auto'
/**
* 大屏侧赛事配置。字段与 shared 契约一致;
* 实时赛况页的两个赛事设置键(settings 表里的 `display.live_events` / `display.live_source`)。
*
* 它们是**管理端的配置**:liveEvents 是人工指定的项目,liveSource 决定服务端怎么判定当前项目。
* 判定结论由 GET /api/display/live 下发(auto 时 liveEvents 通常是空的),
* 这两个键在大屏侧只在接口拿不到时当兜底素材用,缺键时按「没有人工指定 / auto」处理。
*/
export interface LiveDisplaySettings {
/** 人工指定的当前项目 id;auto 模式下未人工指定时为空数组。 */
liveEvents: number[]
liveSource: LiveSource
}
/**
* 大屏侧赛事配置。字段与 shared 契约一致,另外补上实时赛况的两个键;
* `showIndividualMedals` 缺失时由 parseSettings 回退到 `showIndividualBoard`(旧服务端只下发后者)。
*/
export type DisplaySettings = EventSettings
export type DisplaySettings = Omit<EventSettings, 'display'> & {
display: EventSettings['display'] & LiveDisplaySettings
}
/**
* 大屏侧快照:settings 用的是解析后的配置,比 shared 契约多「实时赛况」两个键。
* 其余字段与共享契约一致,缓存与实时消息两条路径都走它。
*/
export interface DisplaySnapshot extends Omit<SnapshotMessage, 'settings'> {
settings: DisplaySettings
}
/**
* GET /api/display/live 的解析结果。
*
* 服务端返回 `{ source, eventIds, events: [{ event, reason, groups }] }`(见 dto.LiveEvents);
* 大屏只用到「判定方式」与每个项目的名字和成绩,eventIds 与 events 一一对应,
* 因此按 events 里的小项 id 落库,reason(manual/scheduled/recent)只作诊断用,不参与渲染。
*/
export interface DisplayLiveFeed {
source: LiveSource
events: LiveEventSource[]
}
/**
* 解析 /api/display/live。
*
* `events` 是契约里的必填数组(没有当前项目时服务端也发 `[]`,不是 null),所以:
* 拿到数组 → 这就是权威结论,空数组表示「现在确实没有项目」;
* 拿不到数组(老服务端 404、代理返回错误页)→ 返回 null,页面当作请求失败,保留上一次画面。
*/
export function parseLiveFeed(value: unknown): DisplayLiveFeed | null {
if (!isRecord(value)) return null
if (!Array.isArray(value.events)) return null
return {
source: oneOf(str(value.source).toLowerCase(), LIVE_SOURCES, 'auto'),
events: value.events
.map(parseLiveEventSource)
.filter((item) => item.eventId > 0),
}
}
function parseLiveEventSource(source: unknown): LiveEventSource {
const rec = record(source)
const event = parseEvent(rec.event)
return {
eventId: event.id,
eventName: event.name,
groups: list(rec.groups).map(parseEventResultGroup),
}
}
/**
* 嵌套路径取值。
@@ -350,6 +418,30 @@ function settingValue(
return source[flatKey]
}
/**
* 当前项目 id 列表。
*
* settings 表里存的是文本,服务端可能下发数组(数字或数字字符串)、单个数字,
* 或 `1,2` 这样的逗号串;这里一律收窄成正整数 id,去重并保持服务端给的顺序。
* 坏值直接丢弃而不是让整包设置解析失败:少一栏总好过整页空态。
*/
export function parseLiveEventIds(value: unknown): number[] {
const raw = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(',')
: typeof value === 'number'
? [value]
: []
const ids: number[] = []
for (const item of raw) {
const id = typeof item === 'number' ? item : Number.parseInt(String(item).trim(), 10)
if (!Number.isFinite(id) || !Number.isInteger(id) || id <= 0) continue
if (!ids.includes(id)) ids.push(id)
}
return ids
}
export function parseSettings(source: unknown): DisplaySettings {
const rec = record(source)
const showIndividualBoard = bool(
@@ -362,6 +454,15 @@ export function parseSettings(source: unknown): DisplaySettings {
// 字体 id 同理:空白归一成 null,调用方按「用默认字体」处理。
const fontTitle = str(settingValue(rec, 'display.fontTitle', 'display.font_title')).trim()
const fontBody = str(settingValue(rec, 'display.fontBody', 'display.font_body')).trim()
// 实时赛况:服务端判定「当前项目」,这里只做收窄;缺键 = 没有进行中的项目。
const liveEvents = parseLiveEventIds(
settingValue(rec, 'display.liveEvents', 'display.live_events'),
)
const liveSource = oneOf(
str(settingValue(rec, 'display.liveSource', 'display.live_source')).toLowerCase(),
LIVE_SOURCES,
'auto',
)
return {
event: {
name: str(settingValue(rec, 'event.name', 'event.name')),
@@ -380,6 +481,8 @@ export function parseSettings(source: unknown): DisplaySettings {
announce: announce === '' ? null : announce,
fontTitle: fontTitle === '' ? null : fontTitle,
fontBody: fontBody === '' ? null : fontBody,
liveEvents,
liveSource,
},
standings: {
individualToTeamWeight: num(
@@ -432,7 +535,7 @@ export function parseTeamBreakdown(value: unknown): TeamBreakdown | null {
}
/** 快照是整包信任的入口,字段缺失时补空数组而不是拒绝整包。 */
export function parseSnapshot(value: unknown): SnapshotMessage | null {
export function parseSnapshot(value: unknown): DisplaySnapshot | null {
if (!isRecord(value)) return null
const version = numOrNull(value.version)
if (version === null) return null
+150
View File
@@ -0,0 +1,150 @@
import type { EventResultGroup, EventResultRow } from '@/shared'
/**
* 实时赛况页的纯逻辑:谁上屏、怎么分栏、栏内排哪些行、什么时候该发下一次请求。
*
* 页面与测试共用这一份判定。全部是纯函数,不依赖 Vue;「当前是哪个项目」由服务端判定
* (GET /api/display/live,见 server/internal/httpapi/live_events.go),大屏只负责渲染。
*/
/** 一屏最多并排几个项目;与服务端的 MaxLiveEvents 一致。 */
export const MAX_LIVE_COLUMNS = 2
/**
* 一栏最多列几个名次。
*
* 行高由 flex 均分、字号按行数缩,名次一多字就小到看不清,索性截断;
* 实时赛况看的是领先的几行,完整名次该由分项成绩页承担。
*/
export const MAX_LIVE_ROWS = 10
/**
* 定时兜底刷新间隔。
*
* 成绩发布有 WS 快照推送,这条定时只负责「靠时间自己过期」的那部分——项目切换的依据是
* scheduled_at 落在 ±30 分钟窗口内、以及最近一次发布,快照不会为时间流逝推送。
* 30s 把切换延迟压在人的感知以内,同时每块屏只有 2 次/分钟的读请求;
* 比 30 分钟窗口小两个数量级,再密只是白打服务端。
*/
export const LIVE_REFRESH_INTERVAL_MS = 30_000
/**
* 两次请求之间的最小间隔。
*
* 三种触发(挂载 / WS 快照 / 定时)撞在一起时靠它合并:落在间隔内的触发只登记一次尾随刷新,
* 既不会同一秒打两次,也不会漏掉这段时间里发布的新成绩。
*/
export const LIVE_REFRESH_MIN_GAP_MS = 4_000
/** 一个「当前项目」的原始素材:小项信息 + 它已发布的全部组别。 */
export interface LiveEventSource {
eventId: number
eventName: string
/** 已发布成绩按「小项 + 组别」分好的组;还没发布过任何成绩时为空数组。 */
groups: readonly EventResultGroup[]
}
export interface LiveColumn {
eventId: number
/** 小项名;拿不到名字时退回「项目 #id」,不留空白。 */
title: string
/** 主栏那一组的组别名;没有成绩时为空串。 */
category: string
/** 同一项目还有几组别没进这一栏(只取最近发布的一组当主栏)。 */
hiddenGroups: number
/** 栏内的名次行:名次升序、无名次排最后、已截断到 MAX_LIVE_ROWS。 */
rows: EventResultRow[]
}
/**
* 接口判定出的「当前项目」→ 屏幕上要画的栏。
*
* 给了几个就画几栏(最多 MAX_LIVE_COLUMNS),顺序照服务端的来;没有项目时返回空数组,
* 页面显示空态。项目还没发布成绩时仍然保留这一栏(rows 为空),由页面显示占位——
* 少一栏会让剩下的一栏铺满全屏,现场看不出另一个项目其实没数据。
*/
export function pickLiveColumns(events: readonly LiveEventSource[]): LiveColumn[] {
const columns: LiveColumn[] = []
for (const item of events) {
if (!Number.isInteger(item.eventId) || item.eventId <= 0) continue
if (columns.some((column) => column.eventId === item.eventId)) continue
const group = pickLatestGroup(item.groups)
columns.push({
eventId: item.eventId,
title: item.eventName.trim() === '' ? `项目 #${item.eventId}` : item.eventName,
category: group?.categoryName ?? '',
hiddenGroups: item.groups.length > 1 ? item.groups.length - 1 : 0,
rows: liveRows(group),
})
if (columns.length >= MAX_LIVE_COLUMNS) break
}
return columns
}
/**
* 接口还没成功过时的兜底素材:人工指定的 id + 快照 latestResults 里能拿到的组。
*
* 只在老服务端(没有 /api/display/live)、断网首屏、示例数据这三种情况下走这条路;
* 快照的 latestResults 受 20 行上限约束,所以它只能算「有总比没有强」。
* 快照里没有该项目时保留这一栏(groups 为空),由页面显示占位而不是静默少一栏。
*/
export function fallbackLiveSources(
ids: readonly number[],
available: readonly EventResultGroup[],
eventNames: ReadonlyMap<number, string> = new Map(),
): LiveEventSource[] {
return ids.map((eventId) => ({
eventId,
eventName: eventNames.get(eventId) ?? '',
groups: available.filter((group) => group.eventId === eventId),
}))
}
/**
* 距离下一次可以发请求还要等多久(毫秒);0 表示现在就能发。
*
* 纯算术,抽出来是为了让「合并去重」这条规则能被测试覆盖;lastAttemptAt 为 0(还没发过)
* 时一定返回 0,页面挂载后立刻就能拿到结论。
*/
export function liveRefreshDelay(
lastAttemptAt: number,
now: number,
minGapMs: number = LIVE_REFRESH_MIN_GAP_MS,
): number {
if (lastAttemptAt <= 0) return 0
return Math.max(0, minGapMs - (now - lastAttemptAt))
}
/** 同一小项分了多个组别时取最近发布的一组;发布时间缺失按最旧算。 */
function pickLatestGroup(groups: readonly EventResultGroup[]): EventResultGroup | null {
let latest: EventResultGroup | null = null
for (const group of groups) {
if (latest === null || publishedAtMs(group) > publishedAtMs(latest)) latest = group
}
return latest
}
function publishedAtMs(group: EventResultGroup): number {
if (group.publishedAt === null) return 0
const parsed = Date.parse(group.publishedAt)
return Number.isFinite(parsed) ? parsed : 0
}
/**
* 栏内的名次行:按名次升序,无名次的排最后,同名次按录入顺序。
*
* 服务端下发前已经排过一次,但本地缓存 / 示例数据不保证顺序,所以这里统一收口;
* 顺带截断到 MAX_LIVE_ROWS。
*/
export function liveRows(group: EventResultGroup | null): EventResultRow[] {
if (group === null) return []
return [...group.rows]
.sort((a, b) => {
if (a.rank === null && b.rank === null) return a.entryId - b.entryId
if (a.rank === null) return 1
if (b.rank === null) return -1
return a.rank - b.rank || a.entryId - b.entryId
})
.slice(0, MAX_LIVE_ROWS)
}
+35 -25
View File
@@ -7,6 +7,7 @@ import type {
StandingRow,
Team,
} from '@/shared'
import type { DisplaySettings } from './guards'
/**
* 后端未就绪时的降级数据(任务要求:降级 mock 并标注「示例数据」)。
@@ -247,35 +248,44 @@ function buildAthletes(): AthleteStanding[] {
})
}
/**
* 示例配置。单列出来是因为它比共享契约多「实时赛况」两个键(后端未就绪时也要能演示):
* 直接写在 SnapshotMessage 字面量里会被多余属性检查拦下。
*/
const MOCK_SETTINGS: DisplaySettings = {
event: {
name: '2026 职工运动会',
subtitle: null,
host: '示例组委会',
logoPath: null,
primaryColor: '#4fd8ff',
},
display: {
background: null,
logo: null,
title: '2026 职工运动会',
subtitle: '积分榜 · 示例数据',
primaryColor: '#4fd8ff',
rotateSeconds: 8,
// 示例数据里有 3 个已完成项目,取前两个演示双栏分栏。
liveEvents: [1, 2],
liveSource: 'manual',
},
standings: {
individualToTeamWeight: 1,
rankingMode: 'points',
showIndividualBoard: true,
showIndividualMedals: true,
},
defaultProfileId: null,
requireApproval: false,
}
export function createMockSnapshot(): SnapshotMessage {
return {
// version 固定 0 且不写入缓存:真实快照一到就能直接覆盖,不会因为版本号更大而被丢弃。
version: 0,
settings: {
event: {
name: '2026 职工运动会',
subtitle: null,
host: '示例组委会',
logoPath: null,
primaryColor: '#4fd8ff',
},
display: {
background: null,
logo: null,
title: '2026 职工运动会',
subtitle: '积分榜 · 示例数据',
primaryColor: '#4fd8ff',
rotateSeconds: 8,
},
standings: {
individualToTeamWeight: 1,
rankingMode: 'points',
showIndividualBoard: true,
showIndividualMedals: true,
},
defaultProfileId: null,
requireApproval: false,
},
settings: MOCK_SETTINGS,
teams: MOCK_TEAMS,
events: buildEvents(),
standings: buildStandings(),
+28
View File
@@ -0,0 +1,28 @@
import { formatResultText } from '@/shared'
import type { EventResultRow, ResultStatus } from '@/shared'
/**
* 成绩行的展示口径:分项成绩页与实时赛况页共用,避免同一行数据在两页显示成两种样子。
*/
const STATUS_LABELS: Record<ResultStatus, string> = {
ok: '',
dq: 'DQ',
dns: 'DNS',
dnf: 'DNF',
withdrawn: '退赛',
}
/** 成绩列:异常状态优先,其次按数值 + 单位格式化,最后退回服务端给的原文。 */
export function displayResult(row: EventResultRow): string {
if (row.status !== 'ok') return STATUS_LABELS[row.status]
if (row.resultValue === null) return row.resultText
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
}
/** 运动员列:个人项目用姓名,团体项目用成员名单,都缺时退回单位简称。 */
export function displayName(row: EventResultRow): string {
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
if (row.memberNames !== null) return row.memberNames.join(' / ')
return row.shortName ?? row.teamName
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { SnapshotMessage } from '@/shared'
import type { DisplaySnapshot } from './guards'
import { isRecord, num, parseSnapshot } from './guards'
/**
@@ -16,7 +16,7 @@ const SNAPSHOT_TTL_MS = 12 * 60 * 60 * 1000
export interface CachedSnapshot {
version: number
savedAt: number
snapshot: SnapshotMessage
snapshot: DisplaySnapshot
}
export function readCachedSnapshot(): CachedSnapshot | null {
@@ -37,7 +37,7 @@ export function readCachedSnapshot(): CachedSnapshot | null {
}
}
export function writeCachedSnapshot(snapshot: SnapshotMessage): void {
export function writeCachedSnapshot(snapshot: DisplaySnapshot): void {
try {
localStorage.setItem(
SNAPSHOT_KEY,
+256
View File
@@ -0,0 +1,256 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { EventResultGroup, EventResultRow } from '@/shared'
import {
MAX_LIVE_ROWS,
fallbackLiveSources,
liveRefreshDelay,
liveRows,
pickLiveColumns,
} from '../live'
import type { LiveEventSource } from '../live'
import { parseLiveEventIds, parseLiveFeed, parseSettings } from '../guards'
import { DISPLAY_PAGES, normalizePage } from '../types'
// 实时赛况页的判定都是纯函数:服务端判定「当前项目」,大屏只决定画几栏、画哪些行。
function row(entryId: number, rank: number | null): EventResultRow {
return {
entryId,
rank,
teamId: 1,
teamCode: 'T01',
teamName: '第一分公司',
shortName: null,
logoPath: null,
athleteId: entryId,
athleteName: `运动员${entryId}`,
memberNames: null,
resultText: '11.58',
resultValue: 11.58,
unit: 's',
isTie: false,
status: 'ok',
}
}
function group(
eventId: number,
categoryId = 1,
publishedAt: string | null = null,
rows: EventResultRow[] = [row(1, 1)],
): EventResultGroup {
return {
eventId,
eventCode: `E${eventId}`,
eventName: `项目${eventId}`,
categoryId,
categoryName: `组别${categoryId}`,
publishedAt,
rows,
}
}
function event(eventId: number, groups: EventResultGroup[] = []): LiveEventSource {
return { eventId, eventName: `项目${eventId}`, groups }
}
test('没有当前项目时返回空栏,页面显示空态', () => {
assert.deepEqual(pickLiveColumns([]), [])
// 全是无效 id 的条目等同于没有项目,不能凭空造出一栏。
assert.deepEqual(pickLiveColumns([event(0), event(-3)]), [])
})
test('一个项目只分一栏,标题/组别/行都来自接口', () => {
const columns = pickLiveColumns([event(2, [group(2, 5, '2026-09-09T15:10:00+08:00')])])
assert.equal(columns.length, 1)
assert.equal(columns[0]?.eventId, 2)
assert.equal(columns[0]?.title, '项目2')
assert.equal(columns[0]?.category, '组别5')
assert.equal(columns[0]?.hiddenGroups, 0)
assert.equal(columns[0]?.rows.length, 1)
})
test('两个项目左右分栏,顺序照服务端给的来', () => {
const columns = pickLiveColumns([event(2, [group(2)]), event(1, [group(1)])])
assert.deepEqual(
columns.map((column) => column.eventId),
[2, 1],
)
assert.deepEqual(
columns.map((column) => column.title),
['项目2', '项目1'],
)
})
test('超过两个项目时只分两栏,重复 id 只算一栏', () => {
const columns = pickLiveColumns([event(1), event(1), event(2), event(3)])
assert.deepEqual(
columns.map((column) => column.eventId),
[1, 2],
)
})
test('项目还没发布成绩时保留占位栏(不静默少一栏)', () => {
const columns = pickLiveColumns([event(1, [group(1)]), event(9)])
assert.equal(columns.length, 2)
assert.equal(columns[0]?.rows.length, 1)
assert.equal(columns[1]?.eventId, 9)
assert.equal(columns[1]?.title, '项目9')
assert.equal(columns[1]?.category, '')
assert.deepEqual(columns[1]?.rows, [])
})
test('接口没给名字时标题退回「项目 #id」', () => {
const columns = pickLiveColumns([{ eventId: 12, eventName: ' ', groups: [] }])
assert.equal(columns[0]?.title, '项目 #12')
})
test('同一项目多组别时取发布时间最近的一组,其余组只报数量', () => {
const older = group(1, 1, '2026-09-09T09:00:00+08:00')
const newer = group(1, 2, '2026-09-09T15:00:00+08:00')
const columns = pickLiveColumns([event(1, [older, newer])])
assert.equal(columns[0]?.category, '组别2')
assert.equal(columns[0]?.hiddenGroups, 1)
// 发布时间缺失按最旧处理,不会把没有时间的组顶掉有时间的组。
const noTime = group(1, 3, null)
assert.equal(pickLiveColumns([event(1, [noTime, newer])])[0]?.category, '组别2')
assert.equal(pickLiveColumns([event(1, [noTime, older])])[0]?.category, '组别1')
})
test('栏内按名次排序、无名次排最后,并截断到上限', () => {
const rows = [row(9, null), row(3, 3), row(1, 1), row(2, 2)]
assert.deepEqual(
liveRows(group(1, 1, null, rows)).map((item) => item.rank),
[1, 2, 3, null],
)
assert.deepEqual(liveRows(null), [])
const many = Array.from({ length: MAX_LIVE_ROWS + 5 }, (_, index) => row(index + 1, index + 1))
assert.equal(liveRows(group(1, 1, null, many)).length, MAX_LIVE_ROWS)
})
test('接口还没成功过时的兜底素材:人工指定 id + 快照分组 + 小项名', () => {
const names = new Map([[7, '男子 1500 米']])
const sources = fallbackLiveSources([7, 3], [group(7), group(8)], names)
assert.deepEqual(
sources.map((item) => ({ id: item.eventId, name: item.eventName })),
[
{ id: 7, name: '男子 1500 米' },
{ id: 3, name: '' },
],
)
// 快照里没有的项目保留空组,页面显示占位栏而不是少一栏。
assert.equal(sources[0]?.groups.length, 1)
assert.deepEqual(sources[1]?.groups, [])
const columns = pickLiveColumns(sources)
assert.equal(columns.length, 2)
assert.equal(columns[0]?.title, '男子 1500 米')
assert.equal(columns[1]?.title, '项目 #3')
})
test('parseLiveFeed 解析 /api/display/live 的真实形状', () => {
const feed = parseLiveFeed({
source: 'AUTO',
eventIds: [7, 3],
events: [
{
event: { id: 7, name: '男子 1500 米', status: 'ongoing' },
reason: 'scheduled',
groups: [
{
eventId: 7,
eventCode: 'E7',
eventName: '男子 1500 米',
categoryId: 1,
categoryName: '青年组',
publishedAt: '2026-09-11T15:20:00+08:00',
rows: [{ entryId: 11, rank: 1, teamId: 1, teamName: '第一分公司', resultText: '4:10.88' }],
},
],
},
// 没成绩的项目:groups 为空数组,仍要成为一栏。
{ event: { id: 3, name: '女子跳高' }, reason: 'scheduled', groups: [] },
],
})
assert.equal(feed?.source, 'auto')
assert.equal(feed?.events.length, 2)
assert.equal(feed?.events[0]?.eventId, 7)
assert.equal(feed?.events[0]?.groups[0]?.categoryName, '青年组')
assert.equal(feed?.events[0]?.groups[0]?.rows[0]?.rank, 1)
assert.deepEqual(feed?.events[1]?.groups, [])
// 空 events 是权威结论:现在确实没有进行中的项目(页面显示空态,而不是保留旧画面)。
assert.deepEqual(parseLiveFeed({ source: 'auto', eventIds: [], events: [] })?.events, [])
// 形状不对时返回 null,由调用方保留上一次画面。
assert.equal(parseLiveFeed(null), null)
assert.equal(parseLiveFeed('{}'), null)
assert.equal(parseLiveFeed({ source: 'auto' }), null)
assert.equal(parseLiveFeed({ events: {} }), null)
// 小项 id 非法(event 缺失/坏数据)的条目直接丢掉,不让它占一栏。
assert.equal(parseLiveFeed({ events: [{ reason: 'recent', groups: [] }] })?.events.length, 0)
})
test('请求间隔:首次立刻发,间隔内返回剩余时间,超过间隔返回 0', () => {
const gap = 4_000
assert.equal(liveRefreshDelay(0, 1_000_000, gap), 0)
assert.equal(liveRefreshDelay(1_000_000, 1_000_500, gap), 3_500)
assert.equal(liveRefreshDelay(1_000_000, 1_004_000, gap), 0)
assert.equal(liveRefreshDelay(1_000_000, 1_060_000, gap), 0)
})
test('实时赛况的两个设置键容错解析', () => {
assert.deepEqual(parseLiveEventIds([3, '4', 0, 'x', 3]), [3, 4])
assert.deepEqual(parseLiveEventIds('5, 6'), [5, 6])
assert.deepEqual(parseLiveEventIds(7), [7])
assert.deepEqual(parseLiveEventIds(null), [])
assert.deepEqual(parseLiveEventIds({ eventId: 1 }), [])
})
test('parseSettings 归一化 liveEvents / liveSource,缺键时按没有项目处理', () => {
const parsed = parseSettings({
display: { liveEvents: [2, '1'], liveSource: 'MANUAL' },
})
assert.deepEqual(parsed.display.liveEvents, [2, 1])
assert.equal(parsed.display.liveSource, 'manual')
// settings 表里是点号键时也要能读到(旧版缓存形态)。
const flat = parseSettings({ 'display.live_events': '8,9', 'display.live_source': 'auto' })
assert.deepEqual(flat.display.liveEvents, [8, 9])
const missing = parseSettings({})
assert.deepEqual(missing.display.liveEvents, [])
assert.equal(missing.display.liveSource, 'auto')
})
test('page=live 能过页名归一化,页名是「实时赛况」', () => {
assert.equal(DISPLAY_PAGES.find((page) => page.key === 'live')?.title, '实时赛况')
for (const value of ['live', 'LIVE', ' live ', '实时赛况', '实时', '赛况']) {
assert.equal(normalizePage(value), 'live')
}
// 不能把别的页名错归到实时赛况上。
assert.equal(normalizePage('event-results'), 'event-results')
})
test('页面按栏数切 class,三种文案都不留白', () => {
const source = readFileSync(
new URL('../components/pages/LivePage.vue', import.meta.url),
'utf8',
)
// 分栏比例由 CSS 决定(.boards-1 / .boards-2 各一套网格),class 必须跟着栏数走。
assert.match(source, /boards-\$\{boards\.length\}/)
// 首屏加载中 / 确实没有项目 / 指定项目还没成绩,三种情况都要有明确中文。
assert.match(source, /正在获取当前项目…/)
assert.match(source, /暂无进行中的项目/)
assert.match(source, /该项目暂无已发布的成绩/)
assert.match(source, /:path="row\.logoPath"/)
// 定时兜底必须挂在页面上,否则项目切换会停在旧结论。
assert.match(source, /LIVE_REFRESH_INTERVAL_MS/)
// 数据来自接口结论(liveFeed),且挂载时立刻拉一次。
assert.match(source, /liveFeed/)
assert.match(source, /onMounted\(\(\) => \{\s*void refreshLiveEvents\(\)/)
})
+6
View File
@@ -8,6 +8,7 @@ export const DISPLAY_PAGES = [
{ key: 'team-medals', title: '团体奖牌榜' },
{ key: 'athlete-standings', title: '个人积分奖牌榜' },
{ key: 'event-results', title: '分项成绩' },
{ key: 'live', title: '实时赛况' },
{ key: 'announcement', title: '公告' },
] as const
@@ -34,6 +35,11 @@ const PAGE_ALIASES: Record<string, DisplayPageKey> = {
results: 'event-results',
分项成绩: 'event-results',
成绩: 'event-results',
live: 'live',
'live-results': 'live',
实时赛况: 'live',
实时: 'live',
赛况: 'live',
announcement: 'announcement',
announce: 'announcement',
公告: 'announcement',
+98 -3
View File
@@ -4,21 +4,23 @@ import type {
AthleteStanding,
Change,
DisplayCommand,
Event,
EventResultGroup,
RealtimeStatus,
SnapshotMessage,
StandingRow,
} from '@/shared'
import {
parseLiveFeed,
parseSettings,
parseSnapshot,
parseStandingRow,
parseStandingRows,
parseTeamBreakdown,
} from './guards'
import type { DisplaySettings } from './guards'
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { applyDisplayFonts } from './fonts'
import { liveRefreshDelay } from './live'
import { createMockAthletes, createMockSnapshot } from './mock'
import { readCachedSnapshot, writeCachedSnapshot } from './storage'
import { DISPLAY_PAGES, normalizePage } from './types'
@@ -45,6 +47,17 @@ const settings = ref<DisplaySettings | null>(null)
const standings = ref<StandingRow[]>([])
const athletes = ref<AthleteStanding[]>([])
const latestResults = ref<EventResultGroup[]>([])
// 小项清单:实时赛况页要显示「当前项目」的名字,而指定项目不一定在 latestResults 里。
const events = ref<Event[]>([])
/**
* 「实时赛况」的当前项目结论(GET /api/display/live)。
*
* 判定依赖当前时间与最近一次发布,装不进只在变更时推送的快照,所以单独拉;
* null = 还没成功拉到过,页面退回「人工指定 + 快照分组」的兜底素材。
*/
const liveFeed = ref<DisplayLiveFeed | null>(null)
/** 最近一次拉取是否失败;失败时不动 liveFeed(现场要求:下一项目开始前一直显示上一个项目的成绩)。 */
const liveStale = ref(false)
const rankDelta = ref<Record<number, number>>({})
const page = ref<DisplayPageKey>('standings')
const locked = ref(true)
@@ -84,6 +97,11 @@ let rankTimer: ReturnType<typeof setTimeout> | null = null
let detailTimer: ReturnType<typeof setTimeout> | null = null
let pageTimer: ReturnType<typeof setInterval> | null = null
let athletesLoading = false
// 「实时赛况」刷新的合并去重状态:见 refreshLiveEvents。
let liveLoading = false
let liveQueued = false
let liveLastAttemptAt = 0
let liveQueueTimer: ReturnType<typeof setTimeout> | null = null
/** 上一次见过的「服务端持久化公告」,用来判断设置里的值是真的变了。 */
let seenSettingsAnnounce = ''
@@ -143,9 +161,10 @@ function applyStandings(rows: StandingRow[]): void {
}, RANK_DELTA_MS)
}
function applySnapshot(snapshot: SnapshotMessage, kind: DisplaySource): void {
function applySnapshot(snapshot: DisplaySnapshot, kind: DisplaySource): void {
settings.value = snapshot.settings
latestResults.value = snapshot.latestResults
events.value = snapshot.events
version.value = snapshot.version
applyStandings(snapshot.standings)
source.value = kind
@@ -203,6 +222,73 @@ async function refreshAthletes(): Promise<void> {
}
}
/**
* 记一条拉取失败的日志:只在「刚变坏」时记。
*
* 服务端长时间不可用时定时轮询会一直失败,每次都记会把日志刷成噪声;
* 现场需要的是「什么时候开始坏、什么时候恢复」,中间的重复失败没有信息量。
*/
function reportLiveFailure(wasStale: boolean, reason: string): void {
if (wasStale) return
console.warn('[实时赛况]', `拉取当前项目失败,继续显示上一次成绩:${reason}`)
}
/**
* 拉一次「实时赛况」的当前项目(GET /api/display/live)。
*
* 三种触发都走这里:页面挂载、WS 快照变更(成绩发布 → 快照会推)、定时兜底。
* 合并去重靠 liveRefreshDelay:请求还在飞、或距上次尝试不足最小间隔时,只登记一次
* 尾随刷新,既不会同一秒打两次,也不会漏掉这段窗口里发布的新成绩。
*
* 失败只挂 liveStale:现场要求「下一项目开始前持续显示上一个项目的成绩」,
* 拿不到新结论时宁可显示旧画面,也不能把屏幕清空(老服务端没有这个接口时同样如此)。
*/
async function refreshLiveEvents(): Promise<void> {
if (liveLoading) {
liveQueued = true
return
}
const wait = liveRefreshDelay(liveLastAttemptAt, Date.now())
if (wait > 0) {
liveQueued = true
if (liveQueueTimer === null) {
liveQueueTimer = setTimeout(() => {
liveQueueTimer = null
if (!liveQueued) return
liveQueued = false
void refreshLiveEvents()
}, wait)
}
return
}
liveLastAttemptAt = Date.now()
liveLoading = true
const wasStale = liveStale.value
try {
const feed = parseLiveFeed(await api.display.live())
if (feed === null) {
// 响应形状不对与请求失败同责:都不是一份可信的结论,保留上一份。
liveStale.value = true
reportLiveFailure(wasStale, '响应格式不符')
} else {
liveFeed.value = feed
liveStale.value = false
}
} catch (error) {
liveStale.value = true
reportLiveFailure(wasStale, error instanceof Error ? error.message : String(error))
} finally {
liveLoading = false
if (liveQueued) {
liveQueued = false
// 立刻再调一次:此时仍在最小间隔内,会由上面那条分支排队到间隔之后再发。
void refreshLiveEvents()
}
}
}
// ---------------------------------------------------------------------------
// 断线兜底与启动
// ---------------------------------------------------------------------------
@@ -243,6 +329,7 @@ function activateMock(): void {
const snapshot = createMockSnapshot()
settings.value = parseSettings(snapshot.settings)
latestResults.value = snapshot.latestResults
events.value = snapshot.events
athletes.value = createMockAthletes()
standings.value = snapshot.standings
source.value = 'mock'
@@ -450,6 +537,8 @@ function connect(): void {
writeCachedSnapshot(snapshot)
// 个人榜不在快照里,每次拿到快照(含 resync 结果)都单独补拉一次。
void refreshAthletes()
// 当前项目的判定依赖「最近一次发布」,成绩一发布就要重算;快照就是发布的信号。
void refreshLiveEvents()
},
onCatchup: (message) => {
applyChanges(message.changes)
@@ -498,6 +587,8 @@ function dispose(): void {
stopPageRotation()
clearDetailTimer()
rankTimer = clearTimer(rankTimer)
liveQueueTimer = clearTimer(liveQueueTimer)
liveQueued = false
announcementStore.dispose()
client?.close()
client = null
@@ -521,6 +612,9 @@ export function useScoreboard() {
standings,
athletes,
latestResults,
events,
liveFeed,
liveStale,
page,
authRequired,
options,
@@ -530,6 +624,7 @@ export function useScoreboard() {
booted,
bootstrap,
dispose,
refreshLiveEvents,
rankDeltaFor,
}
}
+15
View File
@@ -11,6 +11,8 @@ import type {
BackupResult,
CurrentUser,
DisplayCommand,
EntryHeatPayload,
EntryHeatResult,
EntryImportResult,
EntryWithResult,
Event,
@@ -20,6 +22,7 @@ import type {
EventSettings,
HealthInfo,
ImportConflictDecision,
LiveEvents,
Page,
PageQuery,
PointAdjustment,
@@ -182,6 +185,13 @@ export interface EntriesApi {
importFile(file: File): Promise<EntryImportResult>
/** 用原报名表 + 冲突决策重跑导入,服务端按决策保留一侧人员字段。 */
resolveImport(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult>
/**
* 批量设置 / 清除批次(heat)。
*
* 幂等;要求 entryIds 非空、都属于同一个小项,heat 为 1-999 的整数或 null(清除)。
* 只改录入分组,不影响任何成绩与积分。
*/
setHeat(payload: EntryHeatPayload): Promise<EntryHeatResult>
}
/** POST /api/uploads 的响应:path 是服务端存储后的路径,url 与其相同,可直接放进 <img src>。 */
@@ -249,6 +259,8 @@ export interface DisplayApi {
command(command: DisplayCommand): Promise<void>
/** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */
instances(): Promise<DisplayInstance[]>
/** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */
live(): Promise<LiveEvents>
}
export interface ExportQuery {
@@ -595,6 +607,8 @@ export class ApiClient {
form.append('decisions', JSON.stringify(decisions))
return http.request<EntryImportResult>('/entries/import/resolve', { method: 'POST', body: form })
},
setHeat: (payload) =>
http.request<EntryHeatResult>('/entries/heat', { method: 'PUT', body: payload }),
}
this.results = createResultsApi(http)
@@ -631,6 +645,7 @@ export class ApiClient {
}
return body.map(readDisplayInstance).filter((item): item is DisplayInstance => item !== null)
},
live: () => http.request<LiveEvents>('/display/live'),
}
this.exports = createExportApi(http)
+46
View File
@@ -152,6 +152,8 @@ export interface Entry {
memberNames: string[] | null
bib: string | null
seed: string | null
/** 录入 / 展示用的批次号(null = 未分批);批次不参与算分,排名与积分仍按「小项 + 组别」算。 */
heat: number | null
status: EntryStatus
remark: string | null
/** 以下为联表展示字段,接口按需带出。 */
@@ -212,6 +214,19 @@ export interface EntryImportResult {
conflicts: ImportConflict[]
}
/** 批量设置批次(PUT /api/entries/heat)的请求体;heat 为 null 表示清除为「未分批」。 */
export interface EntryHeatPayload {
entryIds: number[]
heat: number | null
}
/** 批量设置批次的结果:这一批报名项当前统一的批次号。 */
export interface EntryHeatResult {
updated: number
heat: number | null
entryIds: number[]
}
export type ResultStatus = 'ok' | 'dq' | 'dns' | 'dnf' | 'withdrawn'
export type ResultState = 'draft' | 'published'
@@ -434,6 +449,17 @@ export interface EventSettings {
*/
fontTitle?: string | null
fontBody?: string | null
/**
* 「实时赛况」页人工指定的当前项目 id,最多 2 个;空数组表示没有人工指定。
* 旧服务端不下发时可选,解析层归一成 []。
*/
liveEvents?: number[]
/**
* 「当前项目」的判定方式:manual 只认 liveEvents;auto 走
* 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。
* 旧服务端不下发时可选,解析层归一成 auto。
*/
liveSource?: string | null
}
standings: {
individualToTeamWeight: number
@@ -729,6 +755,26 @@ export interface EventResultGroup {
rows: EventResultRow[]
}
/**
* 「实时赛况」当前项目(`GET /api/display/live`)。
*
* 判定在服务端做(依赖当前时间与最近一次发布),大屏与「大屏控制」页直接消费结论;
* reason 说明入选原因:manual(人工指定)/ scheduled(排期命中)/ recent(最近发布)。
*/
export interface LiveEvent {
event: Event
reason: string
groups: EventResultGroup[]
}
export interface LiveEvents {
/** 本次生效的判定方式:manual 或 auto。 */
source: string
/** 判定出的当前项目 id,最多 2 个,与 events 一一对应。 */
eventIds: number[]
events: LiveEvent[]
}
export interface SnapshotMessage {
version: number
settings: EventSettings