Files
Scoreboard/web/src/admin/pages/EntryPage.vue
T
AskaEth 08a5740fb2 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 自定义大屏模板设计文档)。
2026-09-11 04:05:02 +08:00

1980 lines
71 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import {
eventInputOf,
formatPoints,
hasAggregateChannel,
parseResultValue,
type AvgMode,
type DraftResultInput,
type EntryWithResult,
type Event,
type ResultState,
type ResultStatus,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode, type AvgModeState } from './avgMode'
import {
DISCARD_CONFIRM_MESSAGE,
DISCARD_CONFIRM_TITLE,
createLeaveGuard,
judgeEntryRow,
resultCellHint,
type EntryRowJudgement,
type ResultCellHint,
} from './entryGuards'
import {
DEFAULT_HEAT_SIZE,
MAX_HEAT,
applyHeatToRows,
filterRowsByHeat,
heatLabel,
heatOptions,
maxHeat,
planHeatSplit,
type HeatAllocation,
type HeatFilter,
} from './entryHeat'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import { entryRowLabel } from '../components/baseData/entrySelection'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/** 表格里的一行:报名项 + 正在编辑的成绩。 */
interface EditableRow {
entryId: number
categoryId: number
bib: string | null
teamId: number
athleteId: number | null
teamName: string
teamShort: string
athleteName: string
memberNames: string[] | null
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
heat: number | null
resultText: string
rank: number | null
status: ResultStatus
isTie: boolean
note: string
resultState: ResultState | null
original: string
}
interface Issue {
entryId: number | null
level: 'error' | 'warn'
message: string
}
interface PreviewRow {
teamId: number
name: string
current: number
delta: number
next: number
}
const STATUS_OPTIONS: Array<{ value: ResultStatus; label: string }> = [
{ value: 'ok', label: '有效' },
{ value: 'dns', label: 'DNS 弃赛' },
{ value: 'dq', label: 'DQ 取消' },
{ value: 'dnf', label: 'DNF 未完赛' },
{ value: 'withdrawn', label: '退赛' },
]
const route = useRoute()
const router = useRouter()
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const selectedEventId = ref<number | null>(null)
const detail = ref<Event | null>(null)
const selectedCategoryId = ref<number | null>(null)
const allRows = ref<EditableRow[]>([])
const loading = ref(false)
const saving = ref(false)
const publishing = ref(false)
const unpublishing = ref(false)
const publishDialog = ref(false)
const unpublishDialog = ref(false)
const focusedEntryId = ref<number | null>(null)
/** 成绩格专属的聚焦标记:悬浮提示只跟着它走,别让名次 / 状态格也弹成绩提示。 */
const focusedResultId = ref<number | null>(null)
/** 悬浮提示的弹出方向:贴近表格底部时向上翻,避免被 .mx-table-wrap 的 overflow 裁掉。 */
const hintPlacement = ref<'below' | 'above'>('below')
const tableRef = ref<HTMLElement | null>(null)
/** 批次筛选:默认「全部」,不改变现有的按组别录入习惯。 */
const selectedHeat = ref<HeatFilter>('all')
/** 表格行选择(批量设置批次用),存 entryId。 */
const selectedEntryIds = ref<number[]>([])
/** 「设为第 N 批」的批次号输入;空 = 用推荐的下一个批次号。 */
const heatInput = ref('')
/** 「一键均分」的每批人数。 */
const heatSizeInput = ref(String(DEFAULT_HEAT_SIZE))
const heatBusy = ref(false)
const splitDialog = ref(false)
const splitMessage = ref('')
const recordForEvent = ref<{ resultText: string; resultValue: number } | null>(null)
/** 未保存修改的确认框:切换项目与离开页面共用同一个,风格与发布确认一致。 */
const discardDialog = ref(false)
/** 用户确认丢弃后要执行的动作(切项目 / 重新跳转)。 */
let pendingDiscard: (() => void) | null = null
/** 项目切换的请求序号:只有最新一次请求的响应才允许写入页面状态。 */
let requestSeq = 0
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const eventGroups = computed(() => {
const groups = data.sports
.map((sport) => ({ key: sport.id, name: sport.name, events: data.events.filter((e) => e.sportId === sport.id) }))
.filter((group) => group.events.length > 0)
if (groups.length > 0) return groups
return [{ key: 0, name: '全部项目', events: data.events }]
})
const selectedEvent = computed(() => detail.value ?? (selectedEventId.value === null ? null : (data.eventById(selectedEventId.value) ?? null)))
const categories = computed(() => detail.value?.categories ?? [])
const channels = computed(() => detail.value?.channels ?? [])
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
const manualRank = computed(() => selectedEvent.value?.resultMode === 'manual' || selectedEvent.value?.compare === 'none')
const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
/** 只有配了「按队内平均」通道的小项才需要选平均口径。 */
const showAvgMode = computed(() => hasAggregateChannel(channels.value))
function unitOf(type: Event['resultType']): string {
if (type === 'time') return 's'
if (type === 'distance') return 'm'
if (type === 'count') return '次'
if (type === 'score') return 'pts'
return ''
}
const unit = computed(() => unitOf(selectedEvent.value?.resultType ?? 'none'))
/**
* 成绩格的示例文本。
*
* 必须带「例」字:光秃秃一个数字会被当成已填内容——用户实机就是这么误判成「填了 1:06」、
* 点发布才知道那一格其实是空的。
*/
const RESULT_SAMPLE: Record<string, string> = {
s: '1:06.33',
m: '12.34',
次: '12',
pts: '96.5',
}
const resultPlaceholder = computed(() => {
const sample = RESULT_SAMPLE[unit.value]
// resultType 为 none 时没有单位,给一个中性的数字例子,不留空白示例。
return sample === undefined ? '例 12' : `例 ${sample}`
})
/** 当前组别的全部行(批次筛选之前)。批次选项从它推导。 */
const categoryRows = computed(() =>
allRows.value.filter((row) => selectedCategoryId.value === null || row.categoryId === selectedCategoryId.value),
)
/** 批次下拉选项:全部 → 当前组别已有批次(去重升序)→ 未分批。 */
const heatChoices = computed(() => heatOptions(categoryRows.value))
/** 已有批次号列表,给「设为第 N 批」的输入框做候选(也允许直接写新批次号)。 */
const existingHeats = computed(() =>
[...new Set(categoryRows.value.map((row) => row.heat).filter((heat): heat is number => heat !== null))].sort(
(a, b) => a - b,
),
)
/** 推荐的下一个批次号:接着已有最大号往后排。 */
const nextHeat = computed(() => maxHeat(categoryRows.value) + 1)
/** 表格实际显示的行:组别 + 批次两层筛选;统计、防呆与发布口径都跟着它走。 */
const visibleRows = computed(() => filterRowsByHeat(categoryRows.value, selectedHeat.value))
/** 选中的行(只认当前可见的,避免筛选后误改看不见的行)。 */
const selectedRows = computed(() =>
visibleRows.value.filter((row) => selectedEntryIds.value.includes(row.entryId)),
)
const allVisibleSelected = computed(
() => visibleRows.value.length > 0 && selectedRows.value.length === visibleRows.value.length,
)
/** 「设为第 N 批」真正会用的批次号;填了非法值时为 null(按钮停用)。 */
const heatNumber = computed<number | null>(() => {
const raw = heatInput.value.trim()
if (raw === '') return nextHeat.value
const value = Number(raw)
return Number.isInteger(value) && value >= 1 && value <= MAX_HEAT ? value : null
})
/** 「一键均分」的每批人数;非法时为 0,编排函数据此返回 null。 */
const heatSize = computed(() => {
const raw = heatSizeInput.value.trim()
const value = Number(raw)
return Number.isInteger(value) && value >= 1 ? value : 0
})
/**
* 一键均分的编排:只对未分批的行按显示顺序切片,批次号接着最大号往后排。
* 已分批的行不参与,手工编好的批次不会被冲掉。
*/
const splitPlan = computed(() => planHeatSplit(visibleRows.value, heatSize.value))
/**
* 提示文案里的作用域称谓:筛了批次就说「当前批次」,否则说「该组」。
*
* 统计与防呆都按当前可见行算,而发布 / 撤销发布仍按「小项 + 组别」整体生效,
* 称谓必须跟着可见行走,否则操作员会把「当前批次全发完」误读成整组都已发布。
*/
const scopeWord = computed(() => (selectedHeat.value === 'all' ? '该组' : '当前批次'))
const heatScoped = computed(() => selectedHeat.value !== 'all')
function scorableOf(rows: EditableRow[]): ScorableRow[] {
return rows.map((row) => ({
entryId: row.entryId,
teamId: row.teamId,
athleteId: row.athleteId,
resultValue: parseResultValue(row.resultText, unit.value),
rank: row.rank,
status: row.status,
isTie: row.isTie,
}))
}
function scoreWith(rows: EditableRow[]) {
const event = selectedEvent.value
if (event === null) return null
return computeEventScore(scorableOf(rows), {
event,
category: categories.value.find((item) => item.id === selectedCategoryId.value) ?? null,
channels: channels.value,
profiles: data.profiles,
weight: weight.value,
})
}
/** 全量可见行(含未录行,但没成绩不参与排名)= 本次发布后服务端会算出的口径。 */
const score = computed(() => scoreWith(visibleRows.value))
/**
* 只含「已发布」行的一次独立算分:这是该组此刻在榜上的口径。
*
* 分批比赛时必须单独算:若拿全量口径里已发布行的分去扣,新一批会把老一批的名次挤下去,
* 扣掉的却是老一批在新口径下的(可能更低的)分,差值就不等于本次发布对榜单的真实影响。
*/
const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) => row.resultState === 'published')))
/**
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
*
* 用 rowTeamPoints(各通道原始分,个人通道已按权重折算)而不是服务端 result.points:
* 后者只覆盖已发布行,且编辑中的行服务端还没算分,两边口径无法混用。
*/
const publishedContribution = computed(() => {
const map = new Map<number, number>()
const rowTeamPoints = publishedScore.value?.rowTeamPoints
for (const row of visibleRows.value) {
if (row.resultState !== 'published') continue
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (rowTeamPoints?.get(row.entryId) ?? 0))
}
return map
})
const preview = computed<PreviewRow[]>(() => {
const current = new Map(data.standings.map((row) => [row.teamId, row.totalPoints]))
const next = score.value?.teamPoints ?? new Map<number, number>()
return data.teams
.map((team) => {
const now = current.get(team.id) ?? 0
const delta = round((next.get(team.id) ?? 0) - (publishedContribution.value.get(team.id) ?? 0))
return { teamId: team.id, name: team.shortName ?? team.name, current: now, delta, next: round(now + delta) }
})
.sort((a, b) => b.next - a.next || a.name.localeCompare(b.name))
})
const maxDelta = computed(() => preview.value.reduce((max, row) => Math.max(max, Math.abs(row.delta)), 0))
const affectedTeams = computed(() => preview.value.filter((row) => row.delta !== 0).length)
/** 该组是否已有成绩发布过。分批比赛时可能只是部分发布,不能因此锁死整组。 */
const hasPublished = computed(() => visibleRows.value.some((row) => row.resultState === 'published'))
/** 该组是否已全部发布:只有这时才需要先撤销发布才能继续录入。 */
const allPublished = computed(
() => visibleRows.value.length > 0 && visibleRows.value.every((row) => row.resultState === 'published'),
)
const publishedCount = computed(() => visibleRows.value.filter((row) => row.resultState === 'published').length)
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
/** 路由离开守卫:左侧菜单切页 / 浏览器前进后退都走这里,未保存的成绩不能静默丢失。 */
const leaveGuard = createLeaveGuard(() => dirty.value)
/** 有未保存修改时先弹确认框,确认后才执行动作(切换项目 / 离开页面共用)。 */
function guardDirty(action: () => void): void {
if (!dirty.value) {
action()
return
}
pendingDiscard = action
discardDialog.value = true
}
function confirmDiscard(): void {
const action = pendingDiscard
pendingDiscard = null
discardDialog.value = false
action?.()
}
function cancelDiscard(): void {
pendingDiscard = null
discardDialog.value = false
leaveGuard.cancel()
}
onBeforeRouteLeave((to) => {
if (leaveGuard.beforeLeave(to.fullPath)) return true
// 已拦下:等用户在这唯一一个确认框里做选择,确认后重放这次跳转。
pendingDiscard = () => {
const target = leaveGuard.confirm()
if (target === null) return
// 跳转结束后收回「已放行」:万一这次跳转被别处拦下,下次离开仍要重新确认。
void router.push(target).finally(() => leaveGuard.reset())
}
discardDialog.value = true
return false
})
/** 已发布的行由服务端锁定:只能先撤销发布,不能就地改草稿。 */
function rowLocked(row: EditableRow): boolean {
return row.resultState === 'published'
}
/**
* 每一行的发布判定:✅已填 / ⚠️未录 / 🚫有错。
*
* 未录行只是「还没跑完」,在分批比赛时属于中性状态,不阻塞发布;
* 判定口径抽在 entryGuards 的纯函数里,审核页与录入页共用。
*/
const judgements = computed<Map<number, EntryRowJudgement>>(() => {
const map = new Map<number, EntryRowJudgement>()
for (const [index, row] of visibleRows.value.entries()) {
map.set(
row.entryId,
judgeEntryRow({
label: issueLabel(row, index),
resultText: row.resultText,
unit: unit.value,
status: row.status,
rank: row.rank,
needsRank: manualRank.value,
}),
)
}
return map
})
function rowJudgement(row: EditableRow): EntryRowJudgement | undefined {
return judgements.value.get(row.entryId)
}
// ── 聚焦成绩格的实时悬浮提示 ────────────────────────────────────────────────
/** 气泡大致高度 + 间距:判断输入框下方在滚动容器可视区里够不够放。 */
const HINT_SPACE = 46
/**
* 某一格此刻应有的提示;非 ok 状态没有成绩数值,不提示(与 judgeEntryRow 口径一致)。
* 纯函数只负责「显示什么」,定位与渲染留在模板里。
*/
function resultHintOf(row: EditableRow): ResultCellHint | null {
if (row.status !== 'ok' || rowLocked(row)) return null
return resultCellHint(row.resultText, unit.value)
}
/** 当前聚焦那一格的提示;只有成绩格聚焦时才非空,失焦立即收起。 */
const focusedHint = computed<ResultCellHint | null>(() => {
const id = focusedResultId.value
if (id === null) return null
const row = allRows.value.find((item) => item.entryId === id)
return row === undefined ? null : resultHintOf(row)
})
/** 失焦后由既有的红框 + title 承载原因;合法成绩不写 title,避免无意义悬浮。 */
function resultTitle(row: EditableRow): string {
if (rowLocked(row)) return '已发布,请先撤销发布'
const hint = resultHintOf(row)
return hint !== null && hint.level !== 'ok' ? hint.message : ''
}
/** 贴底就向上翻:拿输入框与滚动容器可视区比较,下方放不下、上方放得下时才改向上。 */
function updateHintPlacement(target: EventTarget | null): void {
const wrap = tableRef.value
if (wrap === null || !(target instanceof HTMLElement)) {
hintPlacement.value = 'below'
return
}
const cell = target.getBoundingClientRect()
const view = wrap.getBoundingClientRect()
const below = view.bottom - cell.bottom
const above = cell.top - view.top
hintPlacement.value = below < HINT_SPACE && above >= HINT_SPACE ? 'above' : 'below'
}
/** 只认成绩格的聚焦:名次 / 状态 / 备注格聚焦时不该弹成绩提示。 */
function onResultFocus(row: EditableRow, event: FocusEvent): void {
focusedEntryId.value = row.entryId
focusedResultId.value = row.entryId
updateHintPlacement(event.target)
}
function onResultBlur(): void {
focusedEntryId.value = null
focusedResultId.value = null
}
/** 每次输入都重测方向:内容变高 / 表格滚动后,「贴底」的判断要跟着更新。 */
function onResultInput(event: InputEvent): void {
updateHintPlacement(event.target)
}
/** 已填 / 未录 / 有错 三类的行数,工具栏与发布确认框都要用。 */
const stateCounts = computed(() => {
const counts = { filled: 0, missing: 0, error: 0 }
for (const row of visibleRows.value) {
const state = judgements.value.get(row.entryId)?.state ?? 'missing'
counts[state] += 1
}
return counts
})
/** 本次发布真正会带上屏的行:未发布且已填(或已录状态)。 */
const pendingRows = computed(() =>
visibleRows.value.filter((row) => row.resultState !== 'published' && rowJudgement(row)?.state === 'filled'),
)
/** 还没录成绩、本次发布会跳过的行。 */
const unrecordedRows = computed(() =>
visibleRows.value.filter((row) => row.resultState !== 'published' && rowJudgement(row)?.state === 'missing'),
)
const issues = computed<Issue[]>(() => {
const list: Issue[] = []
const event = selectedEvent.value
if (event === null) return list
const seenRanks = new Map<number, number>()
for (const row of visibleRows.value) {
const judged = rowJudgement(row)
if (judged === undefined) continue
if (judged.blocker !== null) {
list.push({ entryId: row.entryId, level: 'error', message: judged.blocker })
} else if (judged.warning !== null) {
list.push({ entryId: row.entryId, level: 'warn', message: judged.warning })
}
if (manualRank.value && row.status === 'ok' && row.rank !== null) {
seenRanks.set(row.rank, (seenRanks.get(row.rank) ?? 0) + 1)
}
}
for (const [rank, count] of seenRanks) {
if (count > 1) {
list.push({ entryId: null, level: 'warn', message: `名次 ${rank} 重复 ${count} 次,请确认是否并列(并列需勾选「并列」)` })
}
}
const record = recordForEvent.value
if (record !== null && record.resultValue > 0) {
for (const [index, row] of visibleRows.value.entries()) {
const value = parseResultValue(row.resultText, unit.value)
if (value === null || row.status !== 'ok') continue
const ratio = value / record.resultValue
if (ratio < 0.5 || ratio > 2) {
list.push({
entryId: row.entryId,
level: 'warn',
message: `${issueLabel(row, index)}:${row.resultText} 与赛会纪录 ${record.resultText} 差距过大,疑似录入错误`,
})
}
}
}
return list
})
const errors = computed(() => issues.value.filter((issue) => issue.level === 'error'))
const warnings = computed(() => issues.value.filter((issue) => issue.level === 'warn'))
/** 提醒可能很多(未录行逐条列出),列表只展开前若干条,避免把页面撑爆。 */
const MAX_LISTED_ISSUES = 12
const listedIssues = computed(() => [...errors.value, ...warnings.value].slice(0, MAX_LISTED_ISSUES))
const hiddenIssueCount = computed(() => Math.max(0, errors.value.length + warnings.value.length - listedIssues.value.length))
/** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */
const publishMessage = computed(() => {
const pending = pendingRows.value.length
const missing = unrecordedRows.value.length
const head =
missing > 0
? `${scopeWord.value}还有 ${missing} 人没有成绩,本次将发布已填的 ${pending} 人;其余保持未发布,补录后再发布即可。`
: `本次将发布${scopeWord.value}全部 ${pending} 人。`
// 批次只是展示 / 录入分组:发布仍按「小项 + 组别」整体生效,
// 所以筛了某个批次时要把这件事说清楚,避免以为只发这一批。
const scope = heatScoped.value
? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。'
: ''
return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。发布后立即上屏并重算积分榜。`
})
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
function rowLabel(row: EditableRow): string {
return entryRowLabel(row)
}
/** 报错文案里的行号:与当前页签下看到的表格顺序一致,空行名也要有可定位的占位。 */
function issueLabel(row: EditableRow, index: number): string {
const name = rowLabel(row)
return `第 ${index + 1} 行 ${name === '' ? '(未署名)' : name}`
}
function round(value: number): number {
return Number(value.toFixed(2))
}
/** 空缺成绩的快照必须与 rowDirty 拼出的默认行一致,否则一进页面就亮「已修改」。 */
function snapshotOf(entry: EntryWithResult): string {
const result = entry.result
if (result === null) return ['', '', 'ok', '0', ''].join('|')
return [result.resultText, result.rank ?? '', result.status, result.isTie ? '1' : '0', result.note ?? ''].join('|')
}
function toRow(entry: EntryWithResult): EditableRow {
const result = entry.result
const row: EditableRow = {
entryId: entry.id,
categoryId: entry.categoryId,
bib: entry.bib,
teamId: entry.teamId,
athleteId: entry.athleteId,
teamName: entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位',
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
heat: entry.heat,
resultText: result?.resultText ?? '',
rank: result?.rank ?? null,
status: result?.status ?? 'ok',
isTie: result?.isTie ?? false,
note: result?.note ?? '',
resultState: result?.state ?? null,
original: '',
}
row.original = snapshotOf(entry)
return row
}
function rowDirty(row: EditableRow): boolean {
return row.original !== [row.resultText, row.rank ?? '', row.status, row.isTie ? '1' : '0', row.note].join('|')
}
/** 名次输入框清空时 v-model.number 会给出空串,必须在入口就归一为 null。 */
function rankInputValue(event: InputEvent): number | null {
const raw = (event.target as HTMLInputElement).value.trim()
if (raw === '') return null
const value = Number(raw)
return Number.isFinite(value) ? Math.trunc(value) : null
}
function rankOf(row: EditableRow): number | null {
if (manualRank.value) return row.rank
return score.value?.ranks.get(row.entryId) ?? null
}
function pointsOf(row: EditableRow): number {
return round(score.value?.rowPoints.get(row.entryId) ?? 0)
}
function issueFor(entryId: number): Issue | undefined {
return issues.value.find((issue) => issue.entryId === entryId)
}
/**
* 切换组别:批次筛选与行选择都回到原点。
*
* 批次是「组别之下」的概念,换组别后旧批次号没有意义;选择留着会让人误以为
* 还能对看不见的行做批量设置。
*/
function selectCategory(id: number): void {
if (selectedCategoryId.value === id) return
selectedCategoryId.value = id
selectedHeat.value = 'all'
selectedEntryIds.value = []
}
/** 切换项目会丢弃当前编辑,先让操作员确认;确认框由 guardDirty 负责。 */
function selectEvent(id: number): void {
if (selectedEventId.value === id && detail.value !== null) return
guardDirty(() => void loadEvent(id))
}
/** 真正加载项目:只在确认框放行后调用,加载失败时保持当前项目不变。 */
async function loadEvent(id: number): Promise<void> {
selectedEventId.value = id
const seq = (requestSeq += 1)
loading.value = true
try {
const [event, entries, records] = await Promise.all([
adminApi.eventDetail(id),
adminApi.entries(id),
adminApi.records(),
])
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
if (seq !== requestSeq) return
detail.value = event
avgMode.value = loadAvgMode(id, event.avgMode)
selectedCategoryId.value = event.categories?.[0]?.id ?? null
selectedHeat.value = 'all'
selectedEntryIds.value = []
allRows.value = entries.map(toRow)
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
recordForEvent.value = record === null ? null : { resultText: record.resultText, resultValue: record.resultValue }
} catch (caught) {
if (seq !== requestSeq) return
toast.error(caught instanceof Error ? caught.message : '加载项目失败')
} finally {
if (seq === requestSeq) loading.value = false
}
}
/**
* 切换平均值口径:立即整体回写该小项(PUT /api/events/{id}),成功后 toast。
*
* 与成绩草稿的保存完全分离——这里既不改录入行,也不走 saveDraft;
* 失败时把选择退回原值,避免页面显示的口径与服务端实际生效的口径不一致。
*/
async function pickAvgMode(mode: AvgMode): Promise<void> {
const event = detail.value
if (event === null || !canWrite.value) return
if (data.isMock) {
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报;与「发布」一致直接停用。
toast.error('示例数据模式不能保存口径', '后端未就绪,改动不会写入真实赛事')
return
}
const next = selectAvgMode(avgMode.value, mode)
avgMode.value = next.state
if (!next.shouldSave) return
try {
const saved = await adminApi.client.events.update(event.id, eventInputOf(event, { avgMode: mode }))
avgMode.value = settleAvgMode(avgMode.value, true)
// 服务端返回的是小项本身,组别 / 通道仍以当前页加载的详情为准。
detail.value = {
...saved,
categories: saved.categories ?? event.categories,
channels: saved.channels ?? event.channels,
}
await data.refreshEvents()
toast.success(
'平均值口径已保存',
mode === 'all' ? '没成绩的按 0 计入平均' : '只算有有效成绩的人',
)
} catch (caught) {
avgMode.value = settleAvgMode(avgMode.value, false)
toast.error('保存平均值口径失败', caught instanceof Error ? caught.message : null)
}
}
async function reload(): Promise<void> {
if (selectedEventId.value === null) return
const id = selectedEventId.value
const seq = requestSeq
loading.value = true
try {
const entries = await adminApi.entries(id)
if (seq !== requestSeq) return
allRows.value = entries.map(toRow)
} catch (caught) {
if (seq !== requestSeq) return
toast.error(caught instanceof Error ? caught.message : '重新加载失败')
} finally {
if (seq === requestSeq) loading.value = false
}
}
function buildDraftPayload(): DraftResultInput[] {
// 已发布行由服务端锁定,不能再存草稿(服务端会 409 STATE_INVALID);
// 分批发布时本组会同时存在「已发布」与「未发布」的行,只提交未发布的那些。
return visibleRows.value
.filter((row) => row.resultState !== 'published')
.map((row) => ({
entryId: row.entryId,
rank: manualRank.value ? row.rank : null,
resultText: row.resultText.trim(),
resultValue: parseResultValue(row.resultText, unit.value),
// 不传 unit 时服务端会把已有单位覆盖成 NULL,大屏 100 米就会显示成 66.33。
unit: unit.value,
status: row.status,
isTie: row.isTie,
note: row.note.trim() === '' ? null : row.note.trim(),
}))
}
async function saveDraft(silent = false): Promise<boolean> {
if (selectedEventId.value === null) return false
if (saving.value) return false
if (allPublished.value) {
// 可见行全部 published 后服务端会拒绝这次草稿(载荷为空),必须先撤销发布。
toast.error(`${scopeWord.value}已全部发布`, '修改前请先撤销发布')
return false
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return false
}
const payload = buildDraftPayload()
if (payload.length === 0) {
// 没有未发布的行可提交:服务端会以「results 不能为空」拒绝,
// 这里先给出明确说明,别把一次无意义点击变成一条服务端报错。
toast.info(allPublished.value ? `${scopeWord.value}已全部发布` : `${scopeWord.value}还没有报名项`)
return false
}
saving.value = true
try {
await adminApi.saveDraft({
eventId: selectedEventId.value,
categoryId: selectedCategoryId.value ?? undefined,
results: payload,
})
await reload()
if (!silent) toast.success('草稿已保存')
return true
} catch (caught) {
toast.error('保存草稿失败', caught instanceof Error ? caught.message : null)
return false
} finally {
saving.value = false
}
}
function requestPublish(): void {
if (selectedEventId.value === null) return
if (data.isMock) {
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
return
}
if (!canWrite.value) {
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (allPublished.value) {
toast.error(`${scopeWord.value}已全部发布`, '如需修改请先撤销发布')
return
}
if (pendingRows.value.length === 0) {
toast.error('没有可发布的成绩', '未录成绩的行会被跳过;请先录入至少一条成绩再发布')
return
}
if (errors.value.length > 0) {
toast.error(`有 ${errors.value.length} 处校验未通过`, errors.value[0]?.message ?? null)
return
}
publishDialog.value = true
}
async function confirmPublish(): Promise<void> {
if (selectedEventId.value === null) return
publishing.value = true
const published = pendingRows.value.length
const missing = unrecordedRows.value.length
try {
// 发布作用于服务端已保存的草稿,所以必须先整组落草稿再发布(07 第 3 节)。
// 只提交未发布的行,已发布行由服务端锁定。
await adminApi.saveDraft({
eventId: selectedEventId.value,
categoryId: selectedCategoryId.value ?? undefined,
results: buildDraftPayload(),
})
await adminApi.publish({
eventId: selectedEventId.value,
categoryId: selectedCategoryId.value ?? undefined,
})
publishDialog.value = false
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
toast.success(
'发布成功,积分榜已更新',
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null,
)
} catch (caught) {
toast.error('发布失败', caught instanceof Error ? caught.message : null)
} finally {
publishing.value = false
}
}
async function confirmUnpublish(): Promise<void> {
if (selectedEventId.value === null) return
unpublishing.value = true
try {
await adminApi.unpublish({
eventId: selectedEventId.value,
categoryId: selectedCategoryId.value ?? undefined,
})
unpublishDialog.value = false
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
toast.warning('已撤销发布,大屏将回退该组成绩')
} catch (caught) {
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
} finally {
unpublishing.value = false
}
}
// ── 批次(录入 / 展示分组,不参与算分) ──────────────────────────────────────
/** 批量设置批次的共同前置检查;不通过时给出具体原因。 */
function canEditHeat(): boolean {
if (data.isMock) {
toast.error('示例数据模式不能设置批次', '后端未就绪,批次不会写入真实赛事')
return false
}
if (!canWrite.value) {
toast.error('当前不可设置批次', auth.readOnly ? '请先登录' : '服务端未连接')
return false
}
return true
}
/**
* 顺序提交若干批「设置批次」请求(每个批次号一次 PUT /api/entries/heat)。
*
* 之所以按批次号分批调用:接口的契约是「一批 entry 统一设成同一个 heat」。
* 成功后只就地更新本地行的批次,不整表重载——重载会把还没保存的成绩草稿一起丢掉。
*/
async function submitHeatAllocations(allocations: HeatAllocation[], successMessage: string): Promise<void> {
if (!canEditHeat() || allocations.length === 0) return
heatBusy.value = true
let updated = 0
let failure: Error | null = null
for (const item of allocations) {
try {
const result = await adminApi.setEntriesHeat({ entryIds: item.entryIds, heat: item.heat })
allRows.value = applyHeatToRows(allRows.value, result.entryIds, result.heat)
const done = new Set(result.entryIds)
selectedEntryIds.value = selectedEntryIds.value.filter((id) => !done.has(id))
updated += result.updated
} catch (caught) {
// 中途失败就停:后面的批次依赖同一份显示顺序,硬撑下去只会编出更乱的批次。
failure = caught instanceof Error ? caught : new Error('设置批次失败')
break
}
}
heatBusy.value = false
if (failure !== null) {
toast.error('设置批次失败', failure.message)
return
}
toast.success(successMessage, `${updated} 行已更新`)
}
/** 把选中的行设为输入框里的批次号。 */
async function applyHeatToSelected(): Promise<void> {
const heat = heatNumber.value
if (heat === null) {
toast.error('批次号不合法', `请填 1-${MAX_HEAT} 的整数`)
return
}
await submitHeatAllocations(
[{ heat, entryIds: selectedRows.value.map((row) => row.entryId) }],
`已设为第 ${heat} 批`,
)
}
/** 把选中的行清除为「未分批」。 */
async function clearHeatForSelected(): Promise<void> {
await submitHeatAllocations(
[{ heat: null, entryIds: selectedRows.value.map((row) => row.entryId) }],
'已清除批次',
)
}
/** 表头复选框:勾选 = 并入当前可见行,取消 = 只移除当前可见行。 */
// 参数类型写 globalThis.Event:本文件里的 Event 是共享类型里的小项。
function toggleAllVisible(event: globalThis.Event): void {
const checked = (event.target as HTMLInputElement).checked
const visible = new Set(visibleRows.value.map((row) => row.entryId))
selectedEntryIds.value = checked
? [...new Set([...selectedEntryIds.value, ...visible])]
: selectedEntryIds.value.filter((id) => !visible.has(id))
}
function clearSelection(): void {
selectedEntryIds.value = []
}
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
function requestSplit(): void {
const plan = splitPlan.value
if (plan === null) {
toast.error('没有可编排的行', `当前列表里没有未分批的行,或每批人数不是正整数(1-${MAX_HEAT})`)
return
}
const endHeat = plan.startHeat + plan.batchCount - 1
splitMessage.value =
`将对未分批的 ${plan.rowCount} 行、每 ${plan.perHeat} 行一批进行编排,` +
`共 ${plan.batchCount} 批(第 ${plan.startHeat}${plan.batchCount > 1 ? `–${endHeat}` : ''} 批)。` +
`已分批的 ${visibleRows.value.length - plan.rowCount} 行保持不动。`
splitDialog.value = true
}
async function confirmSplit(): Promise<void> {
const plan = splitPlan.value
splitDialog.value = false
if (plan === null) return
await submitHeatAllocations(plan.allocations, '一键均分完成')
}
function autoSort(): void {
const direction = selectedEvent.value?.compare === 'asc' ? 1 : -1
allRows.value = [...allRows.value].sort((a, b) => {
const av = parseResultValue(a.resultText, unit.value)
const bv = parseResultValue(b.resultText, unit.value)
if (av === null && bv === null) return 0
if (av === null) return 1
if (bv === null) return -1
return direction * (av - bv)
})
toast.info('已按成绩重排(名次仍以比较值为准)')
}
/** Enter 在单元格之间移动;Tab 交给浏览器原生行为。 */
function onCellKeydown(event: KeyboardEvent, element: HTMLElement): void {
if (event.key !== 'Enter' || event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) return
event.preventDefault()
const cells = Array.from(tableRef.value?.querySelectorAll<HTMLElement>('[data-cell]') ?? [])
const index = cells.indexOf(element)
const next = cells[index + 1]
if (next !== undefined) {
next.focus()
if (next instanceof HTMLInputElement) next.select()
}
}
function onPageKeydown(event: KeyboardEvent): void {
if (!event.ctrlKey && !event.altKey) return
if (event.key !== 'Enter') return
event.preventDefault()
if (saving.value || publishing.value || unpublishing.value) return
if (event.altKey) requestPublish()
else void saveDraft()
}
/** 关闭/刷新页面前拦住未保存的编辑,避免一整组成绩静默丢失。 */
function onBeforeUnload(event: BeforeUnloadEvent): void {
if (!dirty.value) return
event.preventDefault()
event.returnValue = ''
}
/**
* Excel 粘贴:两列及以上时,第一列按单位名匹配行,最后一列作为成绩;
* 单列时从当前行往下顺序填充(07 第 2 节第 4 条)。
*/
function onPaste(event: ClipboardEvent, startEntryId: number): void {
const text = event.clipboardData?.getData('text/plain') ?? ''
if (text.trim() === '') return
event.preventDefault()
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
let cursor = visibleRows.value.findIndex((row) => row.entryId === startEntryId)
for (const line of lines) {
const columns = line.split('\t').map((column) => column.trim())
if (cursor < 0 || cursor >= visibleRows.value.length) break
let target = visibleRows.value[cursor]
if (target === undefined) break
if (columns.length >= 2) {
const key = columns[0] ?? ''
const matched = visibleRows.value.find(
(row) => row.teamName.includes(key) || (row.teamShort !== '' && key.includes(row.teamShort)),
)
if (matched !== undefined) target = matched
target.resultText = columns[columns.length - 1] ?? ''
} else {
target.resultText = columns[0] ?? ''
}
cursor = visibleRows.value.indexOf(target) + 1
}
toast.info(`已粘贴 ${lines.length} 行`)
}
function moveFocusBy(delta: number): void {
const cells = Array.from(tableRef.value?.querySelectorAll<HTMLElement>('[data-cell]') ?? [])
if (cells.length === 0) return
const active = document.activeElement
const index = active instanceof HTMLElement ? cells.indexOf(active) : -1
const next = cells[Math.min(Math.max(index + delta, 0), cells.length - 1)]
next?.focus()
}
/** 默认聚焦上次编辑的项目(07 第 7 节);URL 带 eventId 时优先。 */
async function pickInitialEvent(): Promise<void> {
if (selectedEventId.value !== null) return
const query = route.query.eventId
const fromQuery = typeof query === 'string' ? Number(query) : Number.NaN
if (Number.isFinite(fromQuery) && data.eventById(fromQuery) !== undefined) {
selectEvent(fromQuery)
return
}
const fallback = data.events.find((event) => event.status === 'pending') ?? data.events[0]
if (fallback !== undefined) selectEvent(fallback.id)
}
onMounted(() => {
window.addEventListener('beforeunload', onBeforeUnload)
void pickInitialEvent()
})
onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
})
// 项目列表由布局层异步加载,加载完成后补一次默认选中。
watch(
() => data.loaded,
(loaded) => {
if (loaded) void pickInitialEvent()
},
)
</script>
<template>
<div class="entry-page" @keydown="onPageKeydown">
<PageHeader title="成绩录入" :subtitle="selectedEvent?.name ?? '选择左侧项目开始录入'">
<template #actions>
<span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || saving || allPublished"
:title="allPublished ? `${scopeWord}已全部发布,请先撤销发布` : ''"
@click="saveDraft()"
>
<MxIcon name="check" :size="15" /> 存草稿
</button>
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || publishing || data.isMock || pendingRows.length === 0"
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : ''"
@click="requestPublish"
>
<MxIcon name="push" :size="15" /> 发布<span v-if="hasPublished && pendingRows.length > 0">本批</span>
</button>
</template>
</PageHeader>
<div v-if="data.isMock" class="mock-note">
<MxIcon name="warning" :size="15" />
<span>{{ data.mockLabel }},示例数据模式下「发布」已停用,成绩不会写入真实赛事。</span>
</div>
<div class="entry-grid">
<aside class="mx-card project-panel">
<div class="mx-card-head"><span class="mx-card-title">项目</span></div>
<div class="project-list">
<div v-for="group in eventGroups" :key="group.key" class="project-group">
<div class="mx-sidebar-section">{{ group.name }}</div>
<button
v-for="event in group.events"
:key="event.id"
class="project-item"
:class="{ active: event.id === selectedEventId }"
type="button"
@click="selectEvent(event.id)"
>
<span class="project-name">{{ event.name }}</span>
<span class="mx-badge" :class="event.status === 'published' ? 'mx-badge-success' : 'mx-badge-warning'">
{{ event.status === 'published' ? '已发布' : '待录入' }}
</span>
</button>
</div>
<div v-if="data.events.length === 0" class="mx-empty">
<div class="mx-empty-title">没有可录入的项目</div>
<div class="mx-empty-desc">请先在「基础数据」创建小项</div>
</div>
</div>
</aside>
<section class="entry-main">
<div v-if="selectedEvent === null" class="mx-card">
<div class="mx-card-body">
<div class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="entry" :size="28" /></div>
<div class="mx-empty-title">请选择一个项目</div>
<div class="mx-empty-desc">左侧列表按大项分组,红色标记为待录入</div>
</div>
</div>
</div>
<template v-else>
<div class="entry-toolbar">
<div class="toolbar-left">
<div class="mx-tabs" v-if="categories.length > 1">
<button
v-for="category in categories"
:key="category.id"
class="mx-tab"
:class="{ active: category.id === selectedCategoryId }"
type="button"
@click="selectCategory(category.id)"
>
{{ category.name }}
</button>
</div>
<!-- 批次只做展示 / 录入分组:筛选只影响这张表,不改任何计分口径。 -->
<label class="heat-filter">
<span class="heat-filter-label">批次</span>
<select v-model="selectedHeat" class="mx-cell-input heat-filter-select">
<option v-for="option in heatChoices" :key="String(option.value)" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
<div class="toolbar-right">
<span class="entry-counts">
<span class="mx-badge mx-badge-success">已填 {{ stateCounts.filled }}</span>
<span class="mx-badge mx-badge-warning">未录 {{ stateCounts.missing }}</span>
<span v-if="stateCounts.error > 0" class="mx-badge mx-badge-error">有错 {{ stateCounts.error }}</span>
</span>
<span v-if="selectedEvent.resultType !== 'none'" class="unit-hint mono">单位:{{ unit || '—' }}</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="autoSort">
<MxIcon name="adjust" :size="14" /> 按成绩重排
</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="moveFocusBy(1)">
<MxIcon name="keyboard" :size="14" /> 下一格
</button>
<button
v-if="hasPublished"
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!canWrite"
@click="unpublishDialog = true"
>
撤销发布
</button>
</div>
</div>
<div v-if="selectedRows.length > 0" class="heat-bar">
<span class="heat-bar-title"><MxIcon name="entry" :size="15" /> 已选 {{ selectedRows.length }} 行</span>
<label class="heat-field">
<span>批次</span>
<input
v-model="heatInput"
class="mx-cell-input heat-input mono"
type="number"
min="1"
:max="MAX_HEAT"
list="heat-candidates"
:placeholder="String(nextHeat)"
/>
<datalist id="heat-candidates">
<option v-for="heat in existingHeats" :key="heat" :value="heat">第 {{ heat }} 批</option>
</datalist>
</label>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!canWrite || heatBusy || heatNumber === null"
:title="heatNumber === null ? `批次号需要是 1-${MAX_HEAT} 的整数` : ''"
@click="applyHeatToSelected"
>
<MxIcon name="check" :size="14" /> 设为第 {{ heatNumber ?? '—' }} 批
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canWrite || heatBusy"
@click="clearHeatForSelected"
>
<MxIcon name="close" :size="14" /> 清除批次
</button>
<span class="heat-bar-split"></span>
<label class="heat-field">
<span>每批</span>
<input
v-model="heatSizeInput"
class="mx-cell-input heat-size-input mono"
type="number"
min="1"
:max="MAX_HEAT"
/>
<span>人</span>
</label>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!canWrite || heatBusy || splitPlan === null"
:title="splitPlan === null ? '当前列表里没有未分批的行,或每批人数不是正整数' : ''"
@click="requestSplit"
>
<MxIcon name="adjust" :size="14" /> 一键均分
</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="clearSelection">取消选择</button>
</div>
<div v-if="showAvgMode" class="avg-mode-bar">
<span class="avg-mode-title"><MxIcon name="adjust" :size="15" /> 平均值口径</span>
<label
v-for="option in AVG_MODE_OPTIONS"
:key="option.value"
class="avg-mode-option"
:class="{ active: avgMode.mode === option.value }"
>
<input
type="radio"
name="avg-mode"
:value="option.value"
:checked="avgMode.mode === option.value"
:disabled="!canWrite || avgMode.saving"
@change="pickAvgMode(option.value)"
/>
<span class="avg-mode-text">{{ option.label }}</span>
<span class="avg-mode-hint">{{ option.hint }}</span>
</label>
<span v-if="avgMode.saving" class="avg-mode-hint">保存中…</span>
<span class="avg-mode-note">团体成绩 = 该单位成员成绩的平均值</span>
</div>
<div v-if="hasPublished" class="published-note">
<MxIcon name="lock" :size="15" />
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
录完再点一次「发布本批」即可,名次与积分会按已发布成绩整体重算。
</div>
<div class="entry-body">
<div class="mx-card table-card">
<div ref="tableRef" class="mx-table-wrap">
<table class="mx-table mx-table-compact mx-table-entry">
<thead>
<tr>
<th class="center select-col">
<input
type="checkbox"
:checked="allVisibleSelected"
:disabled="visibleRows.length === 0"
title="全选当前可见行"
@change="toggleAllVisible"
/>
</th>
<th class="center">录入</th>
<th>号码</th>
<th class="heat-col">批次</th>
<th>单位</th>
<th>运动员 / 队</th>
<th>成绩输入</th>
<th class="center">名次</th>
<th>状态</th>
<th class="center">积分(自动)</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in visibleRows"
:key="row.entryId"
:class="{
'is-dirty': rowDirty(row),
'is-focus': focusedEntryId === row.entryId,
'row-error': rowJudgement(row)?.state === 'error',
'row-missing': rowJudgement(row)?.state === 'missing',
}"
>
<td class="center">
<input v-model="selectedEntryIds" type="checkbox" :value="row.entryId" />
</td>
<td class="center">
<span
v-if="row.resultState === 'published'"
class="mx-badge mx-badge-success"
title="已发布上屏"
>已发布</span>
<span
v-else-if="rowJudgement(row)?.state === 'error'"
class="mx-badge mx-badge-error"
title="填写有误,会阻止发布"
>有错</span>
<span
v-else-if="rowJudgement(row)?.state === 'missing'"
class="mx-badge mx-badge-warning"
title="还没录成绩,发布时会自动跳过"
>未录</span>
<span v-else class="mx-badge mx-badge-success" title="已填,可发布">已填</span>
</td>
<td class="mono">{{ row.bib ?? '—' }}</td>
<td class="heat-col">
<span :class="row.heat === null ? 'heat-none' : 'heat-tag'">{{ heatLabel(row.heat) }}</span>
</td>
<td>
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
</td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
<td class="result-cell">
<input
v-model="row.resultText"
data-cell
class="mx-cell-input mono result-input"
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error' }"
:disabled="!canWrite || rowLocked(row)"
:title="resultTitle(row)"
:placeholder="resultPlaceholder"
@focus="onResultFocus(row, $event)"
@blur="onResultBlur"
@input="onResultInput"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
@paste="onPaste($event, row.entryId)"
/>
<!--
实时悬浮提示:绝对定位 + pointer-events:none,不占布局、不挡打字;
只在当前聚焦的这一格出现,失焦即收起。
-->
<div
v-if="focusedResultId === row.entryId && focusedHint !== null"
class="result-hint"
:class="[`is-${focusedHint?.level}`, hintPlacement === 'above' ? 'is-above' : 'is-below']"
>
{{ focusedHint?.message }}
</div>
</td>
<td class="center">
<input
v-if="manualRank"
:value="row.rank ?? ''"
data-cell
type="number"
min="1"
class="mx-cell-input rank-input"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
@input="row.rank = rankInputValue($event)"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
/>
<span v-else class="rank-auto mono">{{ rankOf(row) ?? '—' }}</span>
</td>
<td>
<select
v-model="row.status"
data-cell
class="mx-cell-input status-select"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
>
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</td>
<td class="center">
<span class="points-cell mono" :class="{ zero: pointsOf(row) === 0 }">
{{ formatPoints(pointsOf(row)) }}
</span>
</td>
<td>
<input
v-model="row.note"
data-cell
class="mx-cell-input"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
placeholder="可留空"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
/>
</td>
</tr>
<tr v-if="!loading && visibleRows.length === 0">
<td colspan="11">
<div class="mx-empty">
<div class="mx-empty-title">
{{ categoryRows.length === 0 ? '该组还没有报名项' : '该批次没有报名项' }}
</div>
<div class="mx-empty-desc">
{{
categoryRows.length === 0
? '请先导入报名表或在基础数据里添加'
: '换一个批次筛选,或选「全部」'
}}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<aside class="mx-card preview-card">
<div class="mx-card-head">
<span class="mx-card-title">实时积分预览</span>
<span class="mx-badge mx-badge-info">{{ affectedTeams }} 个单位受影响</span>
</div>
<div class="mx-card-body">
<p class="preview-hint">
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
</p>
<ul class="preview-list">
<li v-for="row in preview" :key="row.teamId" class="preview-item">
<span class="preview-name">{{ row.name }}</span>
<span class="preview-delta mono" :class="row.delta > 0 ? 'up' : row.delta < 0 ? 'down' : ''">
{{ row.delta > 0 ? '+' : '' }}{{ formatPoints(row.delta) }}
</span>
<span class="preview-total mono">{{ formatPoints(row.next) }}</span>
</li>
</ul>
</div>
</aside>
</div>
<div v-if="issues.length > 0" class="mx-card issues-card">
<div class="mx-card-head">
<span class="mx-card-title">防呆校验</span>
<span class="issue-counts">
<span v-if="errors.length > 0" class="mx-badge mx-badge-error">{{ errors.length }} 项阻止发布</span>
<span v-if="warnings.length > 0" class="mx-badge mx-badge-warning">{{ warnings.length }} 项提醒(不挡发布)</span>
</span>
</div>
<div class="mx-card-body">
<ul class="issue-list">
<li v-for="(issue, index) in listedIssues" :key="index" :class="issue.level">
<MxIcon :name="issue.level === 'error' ? 'warning' : 'bell'" :size="14" />
<span>{{ issue.message }}</span>
</li>
</ul>
<p v-if="hiddenIssueCount > 0" class="issue-more">还有 {{ hiddenIssueCount }} 条提醒未列出;未录的行会在发布时自动跳过。</p>
</div>
</div>
<p class="hotkey-hint">
<MxIcon name="keyboard" :size="14" />
<kbd>Tab</kbd>/<kbd>Enter</kbd> 移动 · <kbd>Ctrl</kbd>+<kbd>Enter</kbd> 存草稿 ·
<kbd>Alt</kbd>+<kbd>Enter</kbd> 发布 · 支持从 Excel 粘贴「单位 + 成绩」两列
</p>
</template>
</section>
</div>
<ConfirmDialog
:open="publishDialog"
tone="warning"
title="确认发布该组成绩?"
:message="publishMessage"
:details="warnings.map((issue) => issue.message)"
confirm-text="确认发布"
:pending="publishing"
@confirm="confirmPublish"
@cancel="publishDialog = false"
/>
<ConfirmDialog
:open="unpublishDialog"
tone="danger"
title="确认撤销发布?"
message="撤销发布将把该组已发布的成绩退回草稿,并作废已产生的积分流水,大屏会回退到发布前的状态。"
:details="[`影响单位:${affectedTeams} 个`, `积分回退:${formatPoints(maxDelta)} 分`]"
confirm-text="撤销发布"
:pending="unpublishing"
@confirm="confirmUnpublish"
@cancel="unpublishDialog = false"
/>
<ConfirmDialog
:open="splitDialog"
tone="warning"
title="确认一键均分?"
:message="splitMessage"
:details="[
'只编排未分批的行;已经分好批的行一个都不动',
`批次号接着当前最大号往后排,不会从第 1 批覆盖`,
]"
confirm-text="开始编排"
:pending="heatBusy"
@confirm="confirmSplit"
@cancel="splitDialog = false"
/>
<ConfirmDialog
:open="discardDialog"
tone="warning"
:title="DISCARD_CONFIRM_TITLE"
:message="DISCARD_CONFIRM_MESSAGE"
:details="[
`未保存的行:${dirtyCount} 行`,
'如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页',
]"
confirm-text="丢弃并继续"
cancel-text="留在本页"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</div>
</template>
<style scoped>
.mock-note {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 14px;
border-radius: var(--mx-r-sm);
font-size: 13px;
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
}
.entry-grid {
display: grid;
grid-template-columns: 264px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.project-panel {
position: sticky;
top: 0;
max-height: calc(100dvh - 140px);
display: flex;
flex-direction: column;
}
.project-list {
overflow-y: auto;
padding: 8px;
}
.project-group + .project-group {
margin-top: 6px;
}
.project-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 9px 10px;
border-radius: var(--mx-r-sm);
cursor: pointer;
color: var(--mx-t2);
font-size: 13px;
text-align: left;
transition: all var(--mx-d1) var(--mx-ease);
}
.project-item:hover {
background: var(--mx-s-hover);
color: var(--mx-text);
}
.project-item.active {
background: var(--mx-primary-glow);
color: var(--mx-primary);
font-weight: 600;
}
.project-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.entry-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
/*
* 批次筛选下拉:紧挨组别页签,默认停在「全部」,不改变现有录入习惯。
* 只影响这张表的显示,不改任何计分口径。
*/
.heat-filter {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--mx-t3);
}
.heat-filter-select {
width: auto;
min-width: 104px;
}
/* 批量设置批次的临时操作条:只有勾了行才出现。 */
.heat-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
border: 1px solid var(--mx-primary);
background: var(--mx-primary-glow);
}
.heat-bar-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--mx-primary);
}
.heat-field {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--mx-t2);
}
.heat-input {
width: 76px;
text-align: center;
}
.heat-size-input {
width: 64px;
text-align: center;
}
.heat-bar-split {
width: 1px;
height: 20px;
background: var(--mx-divider);
}
/*
* 批次列只放「第 N 批 / 未分批」这类短文本,窄列即可:
* 自动布局下 nowrap + width:1% 会收缩到文字宽度,不会挤坏单位 / 运动员列
* (成绩列的 min-width 是另一套约束,两者互不干扰)。
*/
.heat-col {
white-space: nowrap;
width: 1%;
}
.select-col {
width: 1%;
}
.heat-tag {
font-size: 12px;
font-weight: 600;
color: var(--mx-primary);
}
.heat-none {
font-size: 12px;
color: var(--mx-t3);
}
.unit-hint {
font-size: 12px;
color: var(--mx-t3);
}
.avg-mode-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 16px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
border: 1px solid var(--mx-primary);
background: var(--mx-primary-glow);
}
.avg-mode-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--mx-primary);
}
.avg-mode-option {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
color: var(--mx-t2);
cursor: pointer;
}
.avg-mode-option.active .avg-mode-text {
color: var(--mx-primary);
font-weight: 600;
}
.avg-mode-hint {
font-size: 12px;
color: var(--mx-t3);
}
.avg-mode-note {
margin-left: auto;
font-size: 12px;
color: var(--mx-t3);
}
.published-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(52, 211, 153, 0.08);
color: var(--mx-green);
font-size: 13px;
}
.entry-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 14px;
align-items: start;
}
.table-card {
min-width: 0;
}
.table-card .mx-table-wrap {
max-height: calc(100dvh - 320px);
overflow: auto;
}
.team-cell {
font-weight: 600;
}
.center {
text-align: center;
}
/*
* 成绩格必须放得下 `1:06.33`:表格是自动布局,输入框 width:100% 对列宽没有贡献,
* 这一列会被挤到连 4 位数字都看不见。
* 12px 等宽字体下实测 `1:06.33` 最宽约 50.6px(DejaVu Sans Mono),加 20px 左右内边距
* 与 2px 边框约需 73px;取 112px 留 ~1.5 倍余量,可容纳 12 个等宽字符。
* 输入框与单元格各写一份 min-width:前者保证输入本身够宽,后者保证列被撑开
* (自动布局下有些浏览器只认其中一边)。140 = 112 + 12/16px 左右内边距。
* 单位 / 运动员列是 nowrap,最小宽度天然等于文字宽度,不会被这一列压没。
*/
.result-input {
min-width: 112px;
}
.result-cell {
position: relative;
min-width: 140px;
}
/*
* 成绩格实时提示:绝对定位 + pointer-events:none,不占布局、不改变行高、不挡继续打字。
* 默认挂在输入框下方;贴近表格底部时(.is-above)改挂上方,避免被 .mx-table-wrap 的 overflow 裁掉。
* 颜色沿用令牌:不合法用 --mx-error,可疑用 --mx-amber,解析结果用 --mx-t3。
*/
.result-hint {
position: absolute;
left: 0;
z-index: 20;
width: max-content;
max-width: 280px;
padding: 4px 8px;
border-radius: var(--mx-r-xs);
border: 1px solid var(--mx-border);
background: var(--mx-s3);
box-shadow: var(--mx-sh2);
font-size: 12px;
line-height: 1.4;
white-space: normal;
pointer-events: none;
}
.result-hint.is-below {
top: calc(100% + 4px);
}
.result-hint.is-above {
bottom: calc(100% + 4px);
}
.result-hint.is-ok {
color: var(--mx-t3);
}
.result-hint.is-warn {
color: var(--mx-amber);
border-color: var(--mx-amber);
}
.result-hint.is-error {
color: var(--mx-error);
border-color: var(--mx-error);
}
/*
* 示例文本要一眼看出不是已填内容:更淡(--mx-t3)+ 斜体。
* 只挂在录入表格范围内,不动全局 .mx-cell-input——基础数据等页面也在用它。
*/
.mx-table-entry .mx-cell-input::placeholder {
color: var(--mx-t3);
font-style: italic;
}
.rank-input {
width: 72px;
text-align: center;
}
.status-select {
width: 110px;
}
.rank-auto {
font-size: 14px;
font-weight: 700;
color: var(--mx-primary);
}
.points-cell {
font-size: 15px;
font-weight: 800;
color: var(--mx-green);
}
.points-cell.zero {
color: var(--mx-t3);
font-weight: 600;
}
.preview-card {
position: sticky;
top: 0;
}
.preview-hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
margin-bottom: 12px;
}
.preview-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-item {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
font-size: 13px;
}
.preview-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-delta {
font-weight: 700;
font-size: 12px;
color: var(--mx-t3);
min-width: 42px;
text-align: right;
}
.preview-delta.up {
color: var(--mx-green);
}
.preview-delta.down {
color: var(--mx-red);
}
.preview-total {
font-weight: 800;
min-width: 46px;
text-align: right;
}
.issues-card .mx-card-body {
padding-top: 12px;
}
.issue-counts {
display: flex;
gap: 6px;
}
.issue-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.issue-list li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
line-height: 1.5;
}
.issue-list li.error {
color: var(--mx-red);
}
.issue-list li.warn {
color: var(--mx-amber);
}
.issue-more {
margin-top: 10px;
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
/*
* 三类行的左侧色条:未录(中性,可跳过)用灰色,写错(阻塞发布)用红色。
* 只做细边,不整行染色——录入时仍需看清成绩文字。
*/
.mx-table-entry tbody tr.row-missing td:first-child {
box-shadow: inset 3px 0 0 var(--mx-t3);
}
.mx-table-entry tbody tr.row-error td:first-child {
box-shadow: inset 3px 0 0 var(--mx-red);
}
.entry-counts {
display: inline-flex;
align-items: center;
gap: 6px;
}
.hotkey-hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--mx-t3);
flex-wrap: wrap;
}
kbd {
padding: 1px 6px;
border: 1px solid var(--mx-border);
border-radius: 5px;
background: var(--mx-s3);
font-family: var(--mx-mono);
font-size: 11px;
}
@media (max-width: 1279px) {
.entry-body {
grid-template-columns: 1fr;
}
.preview-card {
position: static;
}
}
@media (max-width: 1023px) {
.entry-grid {
grid-template-columns: 1fr;
}
.project-panel {
position: static;
max-height: none;
}
}
</style>