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

@@ -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))
}