Files
Scoreboard/web/src/admin/pages/EntryPage.vue
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

2441 lines
89 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 EntryStatus,
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 { entryProgress } from './entryProgress'
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
import {
entryBatchOutcome,
entryBatchSizeError,
planEntryBatch,
submitEntryBatch,
type EntryBatchAction,
type EntryBatchPlan,
type EntryBatchRow,
} from './entryBatch'
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 { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
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
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
entryStatus: EntryStatus
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 settlement = ref<SettlementStatus | null>(null)
const settling = ref(false)
const settleDialog = 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')
/**
* 报名项是否参与排名:与服务端 domain.EntryStatus.rankable() 同口径——
* 只有 registered 才计分,退赛 / dq / dns 一律 0 分且不占名次。
* 本地预览若不按这条过滤,退赛行会继续显示原来的名次与分数,和榜单对不上。
*/
function rankableRow(row: EditableRow): boolean {
return row.entryStatus === 'registered'
}
function scorableOf(rows: EditableRow[]): ScorableRow[] {
return rows.filter(rankableRow).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)
/**
* 当前 (小项, 组别) 的未结算状态:非 null 才提示。
*
* 延迟结算关闭时服务端返回空清单,这条提示天然不出现;读不到状态(示例数据 / 断线)时也不提示,
* 辅助信息不该打断现场录入。
*/
const pendingSettlement = computed<SettlementGroup | null>(() => {
if (selectedEventId.value === null || selectedCategoryId.value === null) return null
const groups = settlement.value?.groups ?? []
return (
groups.find(
(group) => group.eventId === selectedEventId.value && group.categoryId === selectedCategoryId.value,
) ?? null
)
})
/** 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
})
/**
* 批次录入进度:分母是当前筛选范围的行数(选中某一批就只数这一批),
* 「已录」判据与发布防呆 / 行徽标共用 entryProgress → judgeEntryRow,不另写一套解析。
*/
const entryBatchProgress = computed(() =>
entryProgress({
rows: categoryRows.value,
filter: selectedHeat.value,
unit: unit.value,
needsRank: manualRank.value,
}),
)
/** 本次发布真正会带上屏的行:未发布且已填(或已录状态)。 */
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
? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。'
: ''
// 延迟结算下「发布即重算积分榜」不再成立,确认框不能再说反话。
const tail =
settlement.value?.liveOnPublish === false
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后再更新。'
: '发布后立即上屏并重算积分榜。'
return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。${tail}`
})
/** 团体 / 接力行没有 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,
entryStatus: entry.status,
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 reloadAfterSettlementChange()
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
const deferred = settlement.value?.liveOnPublish === false
const leftover =
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null
if (deferred && pendingSettlement.value !== null) {
toast.success('已发布本批成绩', `延迟结算:名次与榜单等该组全部录完后再更新${leftover === null ? '' : `;${leftover}`}`)
} else {
toast.success('发布成功,积分榜已更新', leftover)
}
} 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 reloadAfterSettlementChange()
toast.warning('已撤销发布,大屏将回退该组成绩')
} catch (caught) {
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
} finally {
unpublishing.value = false
}
}
// ── 分批结算(延迟结算下的「榜单还没反映这批」提示 + 人工兜底) ────────────────
/** 读当前 (小项, 组别) 的结算状态;失败时按「没有提示」处理,不打断录入。 */
async function refreshSettlement(): Promise<void> {
const id = selectedEventId.value
const categoryId = selectedCategoryId.value
if (id === null) {
settlement.value = null
return
}
try {
const status = await settlementApi.status(id, categoryId)
// 请求期间可能已经切了项目 / 组别,旧响应不许覆盖新状态。
if (id !== selectedEventId.value || categoryId !== selectedCategoryId.value) return
settlement.value = status
} catch {
settlement.value = null
}
}
/** 发布 / 撤销 / 结算之后一起刷新:榜单、项目列表与结算状态。 */
async function reloadAfterSettlementChange(): Promise<void> {
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
await refreshSettlement()
}
/** 立即结算:这是「有人永远录不出成绩」时的人工兜底,成功后榜单按已发布部分更新。 */
async function confirmSettle(): Promise<void> {
if (selectedEventId.value === null) return
settling.value = true
try {
await settlementApi.settle(selectedEventId.value, selectedCategoryId.value ?? undefined)
settleDialog.value = false
await reloadAfterSettlementChange()
toast.success('已按当前已发布成绩结算榜单', '未录完的人仍可继续补录,录完后会再自动结算')
} catch (caught) {
toast.error('立即结算失败', caught instanceof Error ? caught.message : null)
} finally {
settling.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 = []
}
// ── 批量退赛 / 恢复 / 移出报名(作用范围 = 勾选行) ────────────────────────────
/** 批量操作的确认框状态;计划由 planEntryBatch 先算好,确认框只负责展示。 */
const batchPlan = ref<EntryBatchPlan | null>(null)
const batchDialog = ref(false)
const batchBusy = ref(false)
/** 勾选行里已退赛的人数:给「批量恢复」按钮的可用性与文案做依据。 */
const selectedScratchedCount = computed(
() => selectedRows.value.filter((row) => row.entryStatus === 'scratched').length,
)
/** 勾选行里非正常参赛的人数(退赛 / dq / dns),决定「批量退赛」是否还有意义。 */
const selectedUnrankableCount = computed(
() => selectedRows.value.filter((row) => !rankableRow(row)).length,
)
/** 勾选行的报名项状态文案;徽标与确认框共用。 */
const ENTRY_STATUS_LABEL: Record<EntryStatus, string> = {
registered: '正常参赛',
scratched: '已退赛',
dq: '取消资格',
dns: '未出场',
}
function entryStatusTitle(status: EntryStatus): string {
switch (status) {
case 'scratched':
return '已退赛(弃权):0 分且不占名次,可用「批量恢复」撤回'
case 'dq':
return '已被判罚取消资格:0 分且不占名次'
case 'dns':
return '已标记未出场:0 分且不占名次'
}
return '正常参赛'
}
function batchRows(): EntryBatchRow[] {
return selectedRows.value.map((row) => ({
entryId: row.entryId,
name: rowLabel(row),
bib: row.bib,
team: row.teamShort || row.teamName,
categoryId: row.categoryId,
entryStatus: row.entryStatus,
}))
}
/** 批量操作的共同前置检查;不通过时给出具体原因。 */
function canBatch(): boolean {
if (data.isMock) {
toast.error('示例数据模式不能批量操作', '后端未就绪,改动不会写入真实赛事')
return false
}
if (!canWrite.value) {
toast.error('当前不可批量操作', auth.readOnly ? '请先登录' : '服务端未连接')
return false
}
// 批量操作会重算并整表刷新,未保存的成绩修改会被覆盖,必须先落草稿。
if (dirty.value) {
toast.error('有未保存的成绩修改', '批量操作会重算并刷新整表,请先「存草稿」再操作')
return false
}
return true
}
/** 点批量按钮:先算清「动谁、跳过谁、重算几个组别」,再让操作员确认。 */
function requestBatchAction(action: EntryBatchAction): void {
if (!canBatch()) return
const rows = batchRows()
const sizeError = entryBatchSizeError(rows.length)
if (sizeError !== null) {
toast.error('批量操作未提交', sizeError)
return
}
const plan = planEntryBatch(action, rows)
if (plan.changed === 0) {
const first = plan.skipped[0]
toast.info(
'没有可处理的行',
first === undefined ? null : `${first.name}:${first.note}`,
)
return
}
batchPlan.value = plan
batchDialog.value = true
}
function cancelBatchAction(): void {
batchDialog.value = false
batchPlan.value = null
}
async function confirmBatchAction(): Promise<void> {
const plan = batchPlan.value
if (plan === null || batchBusy.value) return
batchBusy.value = true
try {
const result = await submitEntryBatch({ action: plan.action, ids: plan.ids })
batchDialog.value = false
batchPlan.value = null
// 整表重载:状态、名次与积分都由服务端重算后的口径决定,不能只在本地的行上打补丁。
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
clearSelection()
const failed = result.items.filter((item) => !item.ok)
if (failed.length > 0) {
// 部分失败必须逐条说清,不能只报一句「批量操作完成」。
toast.warning(
`批量操作完成(${failed.length} 条未处理)`,
failed.map((item) => `${item.id}:${item.error ?? '失败'}`).join(';'),
)
} else {
toast.success(BATCH_SUCCESS_TITLE[plan.action], entryBatchOutcome(result))
}
} catch (caught) {
toast.error('批量操作失败', caught instanceof Error ? caught.message : null)
} finally {
batchBusy.value = false
}
}
const BATCH_SUCCESS_TITLE: Record<EntryBatchAction, string> = {
scratch: '已批量退赛,积分榜已重算',
restore: '已批量恢复,积分榜已重算',
remove: '已移出报名,积分榜已重算',
}
const BATCH_CONFIRM_TITLE: Record<EntryBatchAction, string> = {
scratch: '确认批量退赛?',
restore: '确认批量恢复?',
remove: '确认移出报名?此操作不可恢复',
}
const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
scratch: '确认退赛',
restore: '确认恢复',
remove: '确认移出',
}
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
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()
},
)
// 换项目 / 换组别后结算状态跟着变;提示是辅助信息,读失败不影响录入。
watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { immediate: true })
</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>
<!--
批次录入进度:贴在工具栏正下方、横跨整行,扫一眼就知道这一批还差几条。
分母是当前筛选范围的行数,判据与发布防呆同源(entryProgress → judgeEntryRow)。
-->
<div class="entry-progress" :class="{ 'is-done': entryBatchProgress.done }">
<span class="entry-progress-text">{{ entryBatchProgress.text }}</span>
<div
class="mx-progress mx-progress-lg entry-progress-track"
:class="{
'mx-progress-success': entryBatchProgress.done,
'mx-progress-warning': !entryBatchProgress.done && entryBatchProgress.error > 0,
}"
>
<div class="mx-progress-bar" :style="{ width: `${entryBatchProgress.percent}%` }"></div>
</div>
</div>
<!--
延迟结算提示:低调地放在进度条下面,只说明「这一组还没结算、榜单不跟批次走」,
不挡录入。右侧「立即结算」是人工兜底(比如有人退赛但没标 scratched)。
-->
<div v-if="pendingSettlement !== null" class="entry-settlement">
<MxIcon name="warning" :size="14" />
<span class="entry-settlement-text">
延迟结算:本组 {{ pendingSettlement.published }}/{{ pendingSettlement.required }} 人已出成绩,
名次与榜单等该组全部录完后一起更新(本页预览的名次仍是实时的)。
</span>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canWrite || settling"
@click="settleDialog = true"
>
立即结算
</button>
</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>
<!--
报名项批量操作栏:作用范围就是上面勾选的行(= 当前批次筛选范围内的行)。
退赛可逆、移出不可逆,所以两者都过确认框,先把「动谁、重算几个组别」说清。
dq / dns 是别人的判定,服务端会跳过,这里也把按钮的可用性说白。
-->
<div v-if="selectedRows.length > 0" class="entry-batch-bar">
<span class="entry-batch-title">
<MxIcon name="entry" :size="15" /> 批量操作 {{ selectedRows.length }} 行
</span>
<span class="entry-batch-hint">
仅作用于当前筛选范围({{ scopeWord }});{{ selectedUnrankableCount }} 人已非正常参赛
</span>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!canWrite || batchBusy || selectedUnrankableCount === selectedRows.length"
:title="
selectedUnrankableCount === selectedRows.length
? '选中的行都已是退赛 / 取消资格 / 未出场,没有可退赛的人'
: '标记为退赛(0 分且不占名次),可随时批量恢复'
"
@click="requestBatchAction('scratch')"
>
<MxIcon name="close" :size="14" /> 批量退赛
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canWrite || batchBusy || selectedScratchedCount === 0"
:title="selectedScratchedCount === 0 ? '选中的行里没有已退赛的人' : `把 ${selectedScratchedCount} 人恢复为正常参赛`"
@click="requestBatchAction('restore')"
>
<MxIcon name="check" :size="14" /> 批量恢复<span v-if="selectedScratchedCount > 0">({{ selectedScratchedCount }})</span>
</button>
<span class="heat-bar-split"></span>
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!canWrite || batchBusy"
title="从报名名单里物理删除,连同成绩与积分流水一起删除,不可恢复"
@click="requestBatchAction('remove')"
>
<MxIcon name="warning" :size="14" /> 批量移出报名
</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="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行)`"
@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>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></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 class="status-cell">
<span
v-if="row.entryStatus !== 'registered'"
class="mx-badge entry-status-badge"
:class="row.entryStatus === 'scratched' ? 'mx-badge-warning' : 'mx-badge-error'"
:title="entryStatusTitle(row.entryStatus)"
>{{ ENTRY_STATUS_LABEL[row.entryStatus] }}</span>
<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="settleDialog"
tone="warning"
title="立即结算该组积分?"
:message="`将按本组当前已发布的成绩重算各单位总分并刷新奖牌榜,未录完的人不计入。`"
:details="[
pendingSettlement === null
? '本组已无未结算内容'
: `本组已发布 ${pendingSettlement.published}/${pendingSettlement.required} 人`,
'结算后继续补录并发布,全组录完时还会再自动结算一次',
]"
confirm-text="立即结算"
:pending="settling"
@confirm="confirmSettle"
@cancel="settleDialog = 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"
/>
<!-- 批量操作确认:移出报名不可恢复,用 danger;退赛 / 恢复可逆,用 warning / info。 -->
<ConfirmDialog
:open="batchDialog"
:tone="batchPlan?.action === 'remove' ? 'danger' : batchPlan?.action === 'restore' ? 'info' : 'warning'"
:title="BATCH_CONFIRM_TITLE[batchPlan?.action ?? 'scratch']"
:message="batchPlan?.message ?? ''"
:details="batchPlan?.details ?? []"
:confirm-text="BATCH_CONFIRM_TEXT[batchPlan?.action ?? 'scratch']"
:pending="batchBusy"
@confirm="confirmBatchAction"
@cancel="cancelBatchAction"
/>
</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);
}
/*
* 报名项批量操作栏:与批次操作条同排出现,勾了行才显示。
* 用警示色边框把「这里有不可恢复动作」和批次操作区分开。
*/
.entry-batch-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-error);
background: var(--mx-error-container);
}
.entry-batch-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--mx-error);
}
.entry-batch-hint {
font-size: 12px;
color: var(--mx-t3);
}
/* 状态列同时放报名项徽标(已退赛等)与成绩状态下拉,横排不换行。 */
.status-cell {
display: flex;
align-items: center;
gap: 6px;
}
.status-cell .status-select {
flex: 1;
min-width: 0;
}
.entry-status-badge {
flex: none;
white-space: nowrap;
}
/*
* 批次列只放「第 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;
}
/*
* 批次录入进度:横跨整行、文字加粗,是这一屏最显眼的「还差几条」指示;
* 进度条本身用 MIUIx 的 mx-progress / mx-progress-success(录完变绿),不引新样式。
*/
.entry-progress {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border: 1px solid var(--mx-border);
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.entry-progress-text {
flex: none;
font-size: 13px;
font-weight: 600;
color: var(--mx-t2);
font-variant-numeric: tabular-nums;
}
.entry-progress.is-done .entry-progress-text {
color: var(--mx-green);
}
/* 延迟结算提示:中性色 + 细边,属于「知道就好」的信息,不抢进度条与成绩格的注意力。 */
.entry-settlement {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px dashed var(--mx-border);
border-radius: var(--mx-r-sm);
background: transparent;
font-size: 12px;
color: var(--mx-t3);
}
.entry-settlement-text {
flex: 1;
min-width: 0;
line-height: 1.6;
}
.entry-progress-track {
flex: 1;
min-width: 120px;
}
/* 姓名后的号码布:小号、弱化、等宽,补一次号码但不抢姓名的位置。 */
.bib-inline {
font-size: 11px;
color: var(--mx-t3);
}
.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>