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
@@ -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="确认发布"
|
||||
|
||||
Reference in new issue
Block a user