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:
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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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="确认发布"
|
||||
|
||||
@@ -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 = '有未保存的成绩修改'
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user