Files
Scoreboard/web/src/admin/pages/EntryPage.vue
T
AskaEth 9663d5bfb0 fix(entry): 修「团体名次 / 批次号 / 每批人数」填数字导致整页白屏(P0);版本 dev_0.5.4
## 现场现象
在「按单位手动录名次」的项目(例如篮球)的录成绩页,往底部「团体名次」输入框里填一个数字,
**整个录入页立刻变空白**,控制台 `TypeError: e.trim is not a function`(栈里是 computed 的 getter)。

## 根因(老 bug,不是联队引入)
- 模板是 `v-model="row.input"` + `type="number"`。
- **Vue 3 的 `vModelText` 对 `type="number"` 会自动做数值转换**
  (`castToNumber = number || (vnode.props && vnode.props.type === 'number')`)→ 填了数字之后值是
  `number`;只有**清空**时 `looseToNumber('')` 才留空串。当初的注释只写了后一半,漏了前一半。
- `teamRanks.ts` 的 `parseTeamRankInput(raw: string)` 里直接 `raw.trim()` → TypeError;
  而它的三个调用点(保存载荷 / 重复名次提示 / 描红)**全在渲染期的 computed 里** → 整页白屏。

证据:0.5.2(联队之前)的 `EntryPage.vue` 里就已是这套写法,`teamRanks.ts` 本轮一个字没改过。

## 修复
- `teamRanks.ts`:`parseTeamRankInput` 参数与 `TeamRankRow.input` 类型如实标注 `string | number`,
  解析前 `String(raw).trim()`;`teamRanksDirty` 归一后比较(否则 `number 3` 与 `string '3'` 会被判成
  「已修改」,保存按钮空亮着却什么都没改)。
- `EntryPage.vue`:**同类第二处** —— 「批量设置批次」的 `heatInput` / `heatSizeInput` 同样是
  `type="number"` 且下游 `.trim()`,**同样会整页白屏**,一并修掉。
- 写法与仓库既有的正确实现保持一致(`CategoriesSection.vue` / `AthletesSection.vue` 的 `toAge()`)。

## 同类隐患体检
把全仓库 **21 个 `type="number"` 的 v-model 绑定点**逐个追了下游用法(解析 / 比较 / `.trim()` / 传接口):
**真正会炸的只有上面这 3 处**,其余 18 处本来就用 `Number()` 归一,安全。

## 验证
- **先红后绿**:修复前 3 例红(`raw.trim is not a function` 原地复现:number 解析抛错、computed 调用链抛错、
  脏判断误报 `true !== false`),修复后 13/13 通过。
- 前端用例 809 → **813**,0 fail;`vue-tsc` / `pnpm build` 退出码 0。
- 全量自检两轮全绿(服务端 14 包 / Console 68 例);退出码直接读,未用管道吞。

## 版本
`dev_0.5.3` → **`dev_0.5.4`**(0.5.3 已产出并记过哈希,不重出同名包);安装器素材同步重生成
(版本号烙在位图里,实测仍只有 `brand.bmp` 的 1x/2x 两张变化)。
2026-09-14 09:57:02 +08:00

3591 lines
138 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, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import {
eventInputOf,
formatPoints,
hasAggregateChannel,
isJointTeam,
jointTeamLabel,
normalizeTeamShares,
parseResultValue,
shareText,
type AvgMode,
type DraftResultInput,
type EntryStatus,
type EntryWithResult,
type Event,
type ResultState,
type ResultStatus,
type TeamRanksView,
type TeamShareView,
} 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 {
hasTeamManualChannel,
teamRankFlaggedTeams,
teamRankRowsOf,
teamRankWarnings,
teamRanksDirty,
teamRanksPayload,
type TeamRankRow,
} from './teamRanks'
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
import {
addShareDraftTeam,
removeShareDraftTeam,
selectableShareTeams,
setShareDraftWeight,
shareDraftDirty,
shareDraftIssues,
shareDraftOf,
shareDraftPayload,
shareDraftView,
submitEntryTeamShares,
type ShareDraftRow,
} from './entryShares'
import {
entryBatchOutcome,
entryBatchSizeError,
planEntryBatch,
submitEntryBatch,
type EntryBatchAction,
type EntryBatchPlan,
type EntryBatchResult,
type EntryBatchRow,
} from './entryBatch'
import {
RESULT_ACTION_CONFIRM,
RESULT_ACTION_SUCCESS,
RESULT_ROW_ACTION_LABEL,
RESULT_ROW_ACTION_TITLE,
RESULT_ROW_ACTION_WORD,
resultActionTarget,
resultActionTargets,
resultCellAction,
type ResultActionTarget,
type ResultRowAction,
} from './entryResultActions'
import {
DEFAULT_HEAT_SIZE,
MAX_HEAT,
applyHeatToRows,
filterRowsByHeat,
heatLabel,
heatOptions,
maxHeat,
planHeatSplit,
type HeatAllocation,
type HeatFilter,
} from './entryHeat'
import { adminApi, sampleDataBlockedNotice } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import EntryPicker from '../components/baseData/EntryPicker.vue'
import { entryRowLabel } from '../components/baseData/entrySelection'
import { useBaseData } from '../components/baseData/useBaseData'
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
/**
* 归属份额(读侧恒为至少一条):单单位就是主单位独占 100%,联队是多条。
*
* 表格的「单位」列与实时积分预览都读它——联队那条的分要拆给各成员单位,
* 只看 teamId 会把整份分记在主单位头上。
*/
teamShares: TeamShareView[]
athleteName: string
memberNames: string[] | null
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
heat: number | null
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
entryStatus: EntryStatus
/** 成绩 id(服务端按它定位作废 / 退回;草稿行也有,未报名时才是 null)。 */
resultId: 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: '退赛' },
]
/**
* 成绩状态文案(下拉选项的反查表)。
*
* 已发布的行不再渲染下拉(disabled 的表单控件收不到点击,正是「点不动」的来源),
* 改用只读按钮显示当前状态,文案必须与下拉选项同源,免得两处各写一份慢慢说岔。
*/
const STATUS_LABEL = Object.fromEntries(STATUS_OPTIONS.map((option) => [option.value, option.label])) as Record<
ResultStatus,
string
>
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[]>([])
// 两个输入框都是 type="number",Vue 的 v-model 会把填进去的数字转成 number(清空才给空串),
// 如实标注 string | number:按下游当字符串用(.trim())会在渲染期抛 TypeError,整页白屏。
/** 「设为第 N 批」的批次号输入;空 = 用推荐的下一个批次号。 */
const heatInput = ref<string | number>('')
/** 「一键均分」的每批人数。 */
const heatSizeInput = ref<string | number>(String(DEFAULT_HEAT_SIZE))
const heatBusy = ref(false)
const splitDialog = ref(false)
const splitMessage = ref('')
/**
* 「补报名」弹窗:录入页这边取消过报名(批量移出 / 导入时漏报)后,回到这里就能把已有名单上的人
* 重新勾进当前小项的当前组别。弹窗内容直接复用基础数据 → 报名导入页的 EntryPicker。
*/
const supplementOpen = ref(false)
/** EntryPicker 读的是基础数据(运动员 / 参赛单位 / 小项组别),录入页平时不加载这一份,打开时补一次。 */
const baseData = useBaseData()
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 currentCategoryName = computed(
() => categories.value.find((category) => category.id === selectedCategoryId.value)?.name ?? '—',
)
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))
/**
* 「团体名次」区块(设计 17 §八第 3 条):只有该组别配了「团体 + 手动名次」通道时才出现。
*
* 这类通道读的是 channel_manual_ranks(按单位录),与成绩表的个人名次是两套数据,
* 所以这里单开一块,而不是塞进成绩表格里。
*/
const showTeamRanks = computed(() => hasTeamManualChannel(channels.value))
/**
* 每个组别各自的编辑中行:组别间互不影响,切走再切回时未保存的输入还在
* (成绩表也是这个口径——切换组别不会丢正在编辑的成绩)。
*/
const teamRanksByCategory = ref(new Map<number, TeamRankRow[]>())
/** 录入接口随行下发的团体名次,按组别 id 缓存;0 号键放服务端没带 categoryId 的那一份。 */
const embeddedTeamRanks = ref(new Map<number, TeamRanksView>())
const teamRanksLoading = ref(false)
const teamRanksSaving = ref(false)
/** 当前组别的团体名次行(表格直接绑它)。 */
const teamRankRows = computed<TeamRankRow[]>(() => {
const categoryId = selectedCategoryId.value
return categoryId === null ? [] : (teamRanksByCategory.value.get(categoryId) ?? [])
})
const teamRankDirty = computed(() => teamRanksDirty(teamRankRows.value))
/** 防呆提示(重复名次 / 名次超出单位数):只提示不拦截。 */
const teamRankIssues = computed(() => teamRankWarnings(teamRankRows.value))
/** 疑似笔误的行:给输入框描红,理由由 teamRankIssues 说清。 */
const teamRankIssueTeams = computed(() => teamRankFlaggedTeams(teamRankRows.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>(() => {
// 输入框填了数字后 v-model 给的是 number,不能直接 .trim()(会抛 TypeError → 整页白屏)。
const raw = String(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 = String(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,
// 联队份额随行喂给预览算分:单单位时它就是主单位独占,与升级前同口径。
shares: row.teamShares.map((share) => ({ teamId: share.teamId, weight: share.weight })),
}))
}
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 rowTeamShares = publishedScore.value?.rowTeamShares
for (const row of visibleRows.value) {
if (row.resultState !== 'published') continue
// 按份额逐份累加到单位:联队那条的分不是整份记在主单位头上的。
for (const part of rowTeamShares?.get(row.entryId) ?? []) {
map.set(part.teamId, (map.get(part.teamId) ?? 0) + part.points)
}
}
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 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。
*
* 团体名次的编辑也算在内:它是独立保存的(PUT team-ranks),
* 不并入这里的话切页会把它静默丢掉,而它同样要重算名次与积分。
*/
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)) || teamRankDirty.value)
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
/** 丢弃确认框的明细:成绩与团体名次分开说,别让人看到「0 行」以为没有改动。 */
const discardDetails = computed(() => {
const details = [`未保存的成绩行:${dirtyCount.value} 行`]
if (teamRankDirty.value) details.push('还有未保存的团体名次(该组「团体名次」区块里)')
details.push('如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页')
return details
})
/** 路由离开守卫:左侧菜单切页 / 浏览器前进后退都走这里,未保存的成绩不能静默丢失。 */
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
})
/**
* 已发布的行由服务端锁定:直接改会让「榜单上是旧数、表格里是新数」。
* 退回草稿(reopen)后 state 变回 draft,这里随即放行——「修改本条」正是靠这一步解锁。
*/
function rowLocked(row: EditableRow): boolean {
return row.resultState === 'published'
}
/**
* 已发布(含已作废)行的锁定提示:这些格子改不了时,悬停要直接指出出路。
*
* `clickable` 指**这一格自己点得动**(成绩格 / 状态格:点一下弹退回草稿的确认框)——
* 名次 / 备注格仍不参与点击,只能说「点成绩格或右侧『修改』」,
* 否则提示就变成一句点不出来的空头承诺。
*
* 已发布行的出路有两条——退回这一条草稿,或整组「撤销发布」;已作废行的出路只有一条——「重新录入」。
*/
function lockedHint(row: EditableRow, clickable = false): string {
if (!rowLocked(row)) return ''
if (row.status === 'withdrawn') {
return '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
}
return clickable
? '已发布:点击这一格可退回草稿修改,改完需重新发布本组;也可以点右侧「修改」或整组「撤销发布」'
: '已发布:点成绩格或右侧「修改」退回草稿后可改,改完需重新发布本组'
}
/**
* 每行的行操作(修改本条 / 作废本条 / 重新录入):判定在 entryResultActions 的纯函数里。
*
* 与 judgements 同口径——按当前可见行算,筛选后看不见的行不会残留按钮。
*/
const rowActions = computed<Map<number, ResultActionTarget[]>>(() => {
const map = new Map<number, ResultActionTarget[]>()
for (const row of visibleRows.value) {
const targets = resultActionTargets(row)
if (targets.length > 0) map.set(row.entryId, targets)
}
return map
})
function rowActionsOf(row: EditableRow): ResultActionTarget[] {
return rowActions.value.get(row.entryId) ?? []
}
/**
* 每一行的发布判定:✅已填 / ⚠️未录 / 🚫有错。
*
* 未录行只是「还没跑完」,在分批比赛时属于中性状态,不阻塞发布;
* 判定口径抽在 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)) {
// 已发布 / 已作废的行都锁着,但出路不同:统一由 lockedHint 说清该点哪个按钮。
// 成绩格自己点得动,用 clickable 那一份文案(「点击这一格」)。
return lockedHint(row, true)
}
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 teamName = entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位'
const row: EditableRow = {
entryId: entry.id,
categoryId: entry.categoryId,
bib: entry.bib,
teamId: entry.teamId,
athleteId: entry.athleteId,
teamName,
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
// 老服务端不下发 teamShares 时按主单位独占补一条(与升级前逐字段一致)。
teamShares: normalizeTeamShares(entry.teamShares, {
teamId: entry.teamId,
weight: 1,
teamCode: entry.teamCode ?? data.teamById(entry.teamId)?.code ?? '',
teamName,
}),
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
heat: entry.heat,
entryStatus: entry.status,
resultId: result?.id ?? null,
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 = []
void loadTeamRanks()
}
/** 切换项目会丢弃当前编辑,先让操作员确认;确认框由 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 {
// 用 entryBoard 而不是 entries:同一次请求就会带回团体手工名次(设计 17 §七),
// 不必再多发一轮 GET team-ranks。
const [event, board, records] = await Promise.all([
adminApi.eventDetail(id),
adminApi.entryBoard(id),
adminApi.records(),
])
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
if (seq !== requestSeq) return
detail.value = event
avgMode.value = loadAvgMode(id, event.avgMode)
// 换项目等于换一份数据:团体名次的草稿与随行视图都先清掉,再归位新的一份。
// 归位必须赶在下面改 selectedCategoryId 之前——组别 watch 会立刻按新组别取数据。
teamRanksByCategory.value = new Map()
collectEmbeddedTeamRanks(board.teamRanks)
selectedCategoryId.value = event.categories?.[0]?.id ?? null
selectedHeat.value = 'all'
selectedEntryIds.value = []
allRows.value = board.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 }
// 组别已定、随行视图已归位,这时候取团体名次:有随行数据就走缓存,否则发一次 GET。
// 不用 watch(selectedCategoryId) 是为了不依赖「赋值后回调晚于归位」这种时序巧合。
void loadTeamRanks()
} 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
}
}
// ── 补报名(复用基础数据 → 报名导入页的「从已有名单勾选」) ────────────────────
/**
* 打开补报名弹窗。
*
* 三道前置检查与批量操作、行操作同一口径:示例数据模式写不进真实赛事、只读身份不能写、
* 有未保存的成绩时先落草稿——提交成功后要整表重拉,没落草稿的编辑会被服务端口径覆盖掉。
*/
async function openSupplement(): Promise<void> {
if (data.isMock) {
const notice = sampleDataBlockedNotice('补报名')
toast.error(notice.title, notice.detail)
return
}
if (!canWrite.value) {
toast.error('当前不可报名', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (dirty.value) {
toast.error('有未保存的修改', '补报名会刷新整表,请先「存草稿」/「保存团体名次」再操作')
return
}
supplementOpen.value = true
// 勾选名单要的是基础数据这一份(运动员 / 参赛单位 / 组别),load 自带缓存,重复打开不会重复请求。
await baseData.load()
}
/**
* 补报名成功:重拉当前小项的报名项。
*
* 服务端建完报名会连带重算名次与积分,整表重载才能同时拿到新行与新口径;只在本地拼一行会留下
* 「表格里有人、名次还是旧的」这种半成品。弹窗里预选的就是当前小项,所以正常情况下提交的
* eventId 就是它;万一在弹窗里换了别的小项,本页不动(那是另一张表的事)。
*/
async function onSupplementSubmitted(eventId: number, categoryId: number | null): Promise<void> {
if (eventId !== selectedEventId.value) return
await reload()
if (categoryId === null || categoryId === selectedCategoryId.value) {
toast.success('补报名成功,本组名单已刷新', '刚补上的人已经排进本组表格')
}
}
// ── 成员单位与份额(联队) ────────────────────────────────────────────────────
/**
* 份额编辑器(devdocs/19)。
*
* 打开时把这条报名**当前**的成员单位摆出来:单单位报名看到的就是孤零零一个主单位、
* 权重留空,什么都不改直接关掉等于没动过——常规报名流程一点没被碰到。
* 需要时在弹窗里加单位、填权重(留空 = 均分),保存后服务端当场重算本组并推榜。
*
* `original` 留着是为了判断「改了没有」:没改就禁用保存按钮,避免把一次没改动的整组替换
* 发出去(服务端会照着做,只是白重算一遍本组)。
*/
interface ShareEditor {
entryId: number
/** 展示用的报名身份:运动员 / 队 + 号码布,让操作员确认改的是哪一条。 */
label: string
/** 这条报名的主单位(entries.team_id):不能从份额里去掉。 */
primaryTeamId: number
primaryName: string
original: ShareDraftRow[]
draft: ShareDraftRow[]
saving: boolean
error: string | null
}
const shareEditor = ref<ShareEditor | null>(null)
const pendingShareTeamId = ref('')
const shareIssues = computed(() => {
const editor = shareEditor.value
if (editor === null) return []
return shareDraftIssues(editor.draft, editor.primaryTeamId, editor.primaryName)
})
const shareChanged = computed(() => {
const editor = shareEditor.value
return editor === null ? false : shareDraftDirty(editor.draft, editor.original)
})
const shareView = computed(() => {
const editor = shareEditor.value
return editor === null ? [] : shareDraftView(editor.draft)
})
/** 比例预览:`第一分公司 67% · 第二分公司 33%`,保存前就看得到分怎么拆。 */
const shareRatioText = computed(() => shareView.value.map(shareText).join(' · '))
/** 还能加进来的单位:不在成员里的都行(已停用的可以,假删除的不要)。 */
const shareCandidates = computed(() => {
const editor = shareEditor.value
return editor === null ? [] : selectableShareTeams(data.teams, editor.draft)
})
const canSaveShares = computed(
() =>
canWrite.value &&
shareEditor.value !== null &&
!shareEditor.value.saving &&
shareChanged.value &&
shareIssues.value.length === 0,
)
function openShareEditor(row: EditableRow): void {
pendingShareTeamId.value = ''
shareEditor.value = {
entryId: row.entryId,
label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`,
primaryTeamId: row.teamId,
primaryName: row.teamName,
original: shareDraftOf(row),
draft: shareDraftOf(row),
saving: false,
error: null,
}
}
function closeShareEditor(): void {
shareEditor.value = null
pendingShareTeamId.value = ''
}
function addShareMember(): void {
const editor = shareEditor.value
if (editor === null) return
const team = data.teams.find((item) => String(item.id) === pendingShareTeamId.value)
if (team === undefined) return
editor.draft = addShareDraftTeam(editor.draft, team)
}
function dropShareMember(teamId: number): void {
const editor = shareEditor.value
if (editor === null || teamId === editor.primaryTeamId) return
editor.draft = removeShareDraftTeam(editor.draft, teamId)
}
function editShareWeight(teamId: number, event: InputEvent): void {
const editor = shareEditor.value
if (editor === null) return
const input = event.target
if (!(input instanceof HTMLInputElement)) return
editor.draft = setShareDraftWeight(editor.draft, teamId, input.value)
}
/**
* 保存份额:整组替换一条报名的成员单位。
*
* 就地更新这一行的份额并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑一并覆盖掉,
* 而份额改动只影响分记在谁头上,不影响任何一行的成绩与名次(那些由本地预览算)。
* 服务端的 400(单位重复 / 权重为负 / 缺主单位…)原样展示在弹窗里,不吞。
*/
async function saveShareEditor(): Promise<void> {
const editor = shareEditor.value
if (editor === null || !canSaveShares.value) return
if (data.isMock) {
const notice = sampleDataBlockedNotice('保存成员单位')
toast.error(notice.title, notice.detail)
return
}
editor.saving = true
editor.error = null
const payload = shareDraftPayload(editor.draft)
try {
const outcome = await submitEntryTeamShares(editor.entryId, payload)
const shares = outcome.teamShares ?? payload
const row = allRows.value.find((item) => item.entryId === editor.entryId)
if (row !== undefined) {
const fallbackName = row.teamName
row.teamShares = normalizeTeamShares(shares, {
teamId: row.teamId,
weight: 1,
teamCode: row.teamShares[0]?.teamCode ?? '',
teamName: fallbackName,
})
}
closeShareEditor()
void data.refreshStandings()
toast.success(
'成员单位已保存',
isJointTeam(row?.teamShares ?? []) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位',
)
} catch (caught) {
editor.error = caught instanceof Error ? caught.message : '保存成员单位失败'
} finally {
editor.saving = false
}
}
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
function collectEmbeddedTeamRanks(views: readonly TeamRanksView[]): void {
const next = new Map<number, TeamRanksView>()
for (const view of views) next.set(view.categoryId ?? 0, view)
embeddedTeamRanks.value = next
}
/**
* 取某个组别的视图:优先用录入接口随行下发的那一份(省一轮请求)。
*
* 服务端不带 categoryId 时无从归位,只有一个组别的事件才敢直接用它——
* 猜错组别会把别的组的名次显示成本组的名次,比多一次请求危险得多,宁可回退到 GET。
*/
function embeddedTeamRanksFor(categoryId: number): TeamRanksView | null {
const exact = embeddedTeamRanks.value.get(categoryId)
if (exact !== undefined) return exact
return categories.value.length === 1 ? (embeddedTeamRanks.value.get(0) ?? null) : null
}
/** 把最新视图写进缓存:无 categoryId 的旧视图可能就是这个组别的,一并清掉免得盖过新值。 */
function cacheTeamRanksView(categoryId: number, view: TeamRanksView): void {
const next = new Map(embeddedTeamRanks.value)
next.delete(0)
next.set(categoryId, view)
embeddedTeamRanks.value = next
}
/**
* 载入当前组别的团体名次。
*
* 已经有未保存输入的组别不重载——否则切走再切回会把操作员刚填的名次冲掉。
*/
async function loadTeamRanks(): Promise<void> {
const eventId = selectedEventId.value
const categoryId = selectedCategoryId.value
if (eventId === null || categoryId === null || !showTeamRanks.value) {
// 该组别没有团体手工通道:即使以前填过也不显示(服务端不会读它)。
return
}
const draft = teamRanksByCategory.value.get(categoryId)
if (draft !== undefined && teamRanksDirty(draft)) return
const embedded = embeddedTeamRanksFor(categoryId)
if (embedded !== null) {
writeTeamRanks(categoryId, teamRankRowsOf(embedded, data.teams))
return
}
const seq = requestSeq
teamRanksLoading.value = true
try {
const view = await adminApi.teamRanks(eventId, categoryId)
if (seq !== requestSeq || categoryId !== selectedCategoryId.value) return
cacheTeamRanksView(categoryId, view)
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
} catch (caught) {
if (seq !== requestSeq) return
// 读不到就按空表处理:这是辅助区块,不能挡住成绩录入。
writeTeamRanks(categoryId, [])
toast.error('读取团体名次失败', caught instanceof Error ? caught.message : null)
} finally {
if (seq === requestSeq) teamRanksLoading.value = false
}
}
function writeTeamRanks(categoryId: number, rows: TeamRankRow[]): void {
const next = new Map(teamRanksByCategory.value)
next.set(categoryId, rows)
teamRanksByCategory.value = next
}
/**
* 保存当前组别的团体名次(PUT .../team-ranks,整组替换)。
*
* 保存会重算该组的名次与积分(成绩表的名次列、积分预览、榜单都会变),
* 所以成功后按「发布」同一套流程整页刷新一次;服务端回的就是保存后的视图,直接回填。
*/
async function saveTeamRanks(): Promise<void> {
const eventId = selectedEventId.value
const categoryId = selectedCategoryId.value
if (eventId === null || categoryId === null) return
if (data.isMock) {
// 与草稿 / 发布同一口径:示例数据模式下写接口没有后端可落库,提示成功等于谎报。
toast.error('示例数据模式不能保存团体名次', '后端未就绪,改动不会写入真实赛事')
return
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (teamRankRows.value.length === 0) {
toast.info('没有可录入的参赛单位')
return
}
teamRanksSaving.value = true
try {
const view = await adminApi.saveTeamRanks(eventId, categoryId, teamRanksPayload(teamRankRows.value))
cacheTeamRanksView(categoryId, view)
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
toast.success('团体名次已保存', '该组名次与积分榜会按新名次重算')
await reloadAfterSettlementChange()
} catch (caught) {
toast.error('保存团体名次失败', caught instanceof Error ? caught.message : null)
} finally {
teamRanksSaving.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 (data.isMock) {
// 与发布 / 批次 / 团体名次同一口径:示例数据模式下写接口没有后端可落库,
// 照发请求只会得到一句网络错误,操作员会以为是自己手滑而不是「这台机没连服务端」。
toast.error('示例数据模式不能保存草稿', '后端未就绪,草稿不会写入真实赛事')
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
if (data.isMock) {
// 与发布同一口径:示例数据模式下 unpublish 只改内存,提示「已撤销发布」等于谎报。
toast.error('示例数据模式不能撤销发布', '后端未就绪,撤销发布不会写入真实赛事')
unpublishDialog.value = false
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
}
// 批量操作会重算并整表刷新,未保存的成绩修改会被覆盖,必须先落草稿。
// 团体名次同样是「重算的输入」,也在这里挡住(dirty 已把它算进去)。
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
}
/**
* 批量操作成功提示的副标题。
*
* 「移出报名」是物理删除、界面上没有撤销键,所以这里多指一句路:要重新报名不必去翻基础数据页,
* 录入界面上就有「补报名」。
*/
function batchOutcomeDetail(result: EntryBatchResult, action: EntryBatchAction): string {
const outcome = entryBatchOutcome(result)
if (action !== 'remove') return outcome
return `${outcome};如需重新报名,点上方「补报名」从已有名单重新勾选`
}
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], batchOutcomeDetail(result, plan.action))
}
} 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: '确认移出',
}
// ── 单条修改 / 作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ──────────
/** 待确认的行操作;非 null 时确认框打开。 */
const resultAction = ref<ResultActionTarget | null>(null)
const resultActionBusy = ref(false)
/** 确认框文案随动作变(修改 / 作废 / 退回草稿的后果不同)。 */
const resultActionCopy = computed(() => RESULT_ACTION_CONFIRM[resultAction.value?.action ?? 'void'])
/**
* 点行操作按钮:先做前置检查(示例数据 / 只读 / 未保存修改),再弹确认框。
*
* 一个按钮一次只带一个动作(同一行可能并排着「修改本条」与「作废本条」),
* 判定仍由纯函数给出:传进来的动作必须在该行成立,否则当没点。
*
* 与批量操作同一口径——它会重算并刷新整表,未保存的编辑必须先落草稿,
* 否则刚填的名次会被服务端返回的口径覆盖掉。
*/
function requestResultAction(target: ResultActionTarget): void {
const current = rowActions.value.get(target.entryId)
if (current === undefined || !current.some((item) => item.action === target.action)) return
if (data.isMock) {
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报(与发布 / 批量操作同口径)。
const notice = sampleDataBlockedNotice(RESULT_ROW_ACTION_WORD[target.action])
toast.error(notice.title, notice.detail)
return
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (dirty.value) {
toast.error('有未保存的修改', '修改 / 作废 / 退回会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
return
}
resultAction.value = target
}
/** 已发布行的格子点一下走哪个动作;草稿行 / 已作废行返回 null(照常直接编辑 / 保持禁用)。 */
function cellAction(row: EditableRow): ResultRowAction | null {
return resultCellAction(row)
}
/**
* 这一格是否保持原来的 disabled 锁定(收不到点击)。
*
* 已作废的行走这里:它的出路是按钮上的「重新录入」,格子不该装成点得动。
* 可点的已发布格子改用 readonly——disabled 的控件连 click 事件都不会派发,弹不出确认框。
*/
function cellDisabled(row: EditableRow): boolean {
return rowLocked(row) && cellAction(row) === null
}
/**
* 点已发布行的成绩格 / 状态格 = 点「修改本条」。
*
* 必须复用 requestResultAction 这一条路:确认框(含原因必填)、示例数据 / 只读 / 未保存修改
* 三道拦截、确认后整表重拉都在里面。格子这里绝不能自己调接口——否则就成了
* 「点一下成绩就悄悄从榜上退下来」。用户取消时也什么都回不去(见 cancelResultAction)。
*/
function requestCellAction(row: EditableRow): void {
const action = cellAction(row)
if (action === null) return
const target = resultActionTarget(row, action)
if (target !== null) requestResultAction(target)
}
/** 确认框回传的原因;空原因由 ConfirmDialog 拦下,这里拿到的一定是填好的。 */
async function confirmResultAction(reason: string): Promise<void> {
const target = resultAction.value
if (target === null || resultActionBusy.value) return
resultActionBusy.value = true
try {
if (target.action === 'void') {
await adminApi.voidResult(target.resultId, { reason })
} else {
// 「修改本条」与「重新录入」是同一个接口:两者都把这一条退回草稿,只是入口语境不同。
await adminApi.reopenResult(target.resultId, { reason })
}
resultAction.value = null
// 名次 / 积分 / 大屏都由服务端重算:整表 + 榜单一起重拉,不在本地行上打补丁。
await reloadAfterSettlementChange()
// 退回草稿后把焦点送回那一格,操作员不必再拿鼠标找一遍就能接着改;
// 作废没有可改的格子(它的出路是「重新录入」),不聚焦。
if (target.action !== 'void') await focusResultCell(target.entryId)
const success = RESULT_ACTION_SUCCESS[target.action]
toast.success(success.title, success.detail)
} catch (caught) {
toast.error(`${RESULT_ROW_ACTION_WORD[target.action]}失败`, caught instanceof Error ? caught.message : null)
} finally {
resultActionBusy.value = false
}
}
/**
* 取消确认框:什么都不做。
*
* 这里只能清掉待办的动作——退回草稿的接口一次都不能提前发出去,
* 否则「点一下格子又点了取消」就会把一条已发布成绩悄悄从榜上退下来。
*/
function cancelResultAction(): void {
if (resultActionBusy.value) return
resultAction.value = null
}
/**
* 把焦点落到某一行的成绩输入格(退回草稿成功后调用)。
*
* 必须等 nextTick:整表刚被 reload 换过一轮,DOM 还没更新完,这时候查不到新那一格的节点。
*/
async function focusResultCell(entryId: number): Promise<void> {
await nextTick()
const input = tableRef.value?.querySelector<HTMLInputElement>(`[data-result-cell="${entryId}"]`)
if (input === null || input === undefined) return
input.focus()
input.select()
}
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
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
// 已发布的行现在是 readonly:浏览器照样会派发 paste 事件(只是不插入文本),
// 放过去的话这一条成绩会被本地悄悄改掉、连确认框都不弹,必须在这里挡住。
const start = visibleRows.value.find((row) => row.entryId === startEntryId)
if (start === undefined || rowLocked(start)) 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()
}
/**
* 进入录入页时默认选中小项:URL 带 eventId 时优先,否则取第一个未开始(pending)的项目,
* 都没有就取列表第一个。
*
* 注意口径:这里**没有**「上次编辑的小项」这种记忆——前端从未持久化过录入位置,
* 所以换人换班后回到录入页看到的是兜底结论,不是他离开时的那个项目。
*/
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 || data.isMock"
: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>
<!--
补报名:取消过报名的人要重新报上,入口就在这里(当前小项 / 组别同一视野内),
不必再跑去「基础数据 → 报名导入」找。打开的是同一个 picker,预选当前小项与组别。
-->
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!canWrite || data.isMock"
:title="
data.isMock
? '示例数据模式下不能补报名'
: '从已有名单勾选,为当前小项的当前组别补录报名项(复用「基础数据 → 报名导入」的勾选界面)'
"
@click="openSupplement"
>
<MxIcon name="entry" :size="14" /> 补报名
</button>
<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 || data.isMock"
@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>
<th class="center action-col">操作</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">
<!--
已作废是独立状态:它仍是 published(所以输入框照样锁着),
但成绩不计入名次与积分,徽标必须先说清这件事,别只显示「已发布」。
-->
<span
v-if="row.resultState === 'published' && row.status === 'withdrawn'"
class="mx-badge mx-badge-error"
title="本条已作废:不计入名次与积分,可用「重新录入」退回草稿"
>已作废</span>
<span
v-else-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
v-if="!isJointTeam(row.teamShares)"
class="team-cell is-editable"
:title="`${row.teamName}|点击设置成员单位与份额(两个单位合并参赛时用)`"
@click="openShareEditor(row)"
>{{ row.teamShort || row.teamName }}</span>
<span
v-else
class="team-cell is-editable is-joint"
:title="`${jointTeamLabel(row.teamShares)}|点击修改成员单位与份额`"
@click="openShareEditor(row)"
>
<span class="mx-badge mx-badge-info">联队</span>
<span v-for="member in row.teamShares" :key="member.teamId" class="joint-member">
{{ shareText(member) }}
</span>
</span>
</td>
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
<td class="result-cell">
<!--
已发布的行不再用 :disabled 锁(disabled 的表单控件收不到点击,
「点不动」正是现场反馈的观感),改成 readonly + 点一下弹确认框:
确认后才退回草稿,取消则什么都不会发生。
-->
<input
v-model="row.resultText"
data-cell
:data-result-cell="row.entryId"
class="mx-cell-input mono result-input"
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error', 'is-locked-cell': cellAction(row) !== null }"
:disabled="!canWrite || cellDisabled(row)"
:readonly="cellAction(row) !== null"
:title="resultTitle(row)"
:placeholder="resultPlaceholder"
@click="requestCellAction(row)"
@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="lockedHint(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>
<!--
已发布(未作废)的行不渲染下拉:下拉在锁定态只能 disabled,而 disabled 控件
收不到点击。换成一个可点的只读按钮,点一下与「修改本条」同一条路
(确认后才退回草稿);已作废的行仍走下面那个 disabled 下拉,保持现状。
-->
<button
v-if="cellAction(row) !== null"
type="button"
data-cell
class="mx-cell-input status-select is-locked-cell"
:disabled="!canWrite"
:title="lockedHint(row, true)"
@click="requestCellAction(row)"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
>{{ STATUS_LABEL[row.status] }}</button>
<select
v-else
v-model="row.status"
data-cell
class="mx-cell-input status-select"
:disabled="!canWrite || rowLocked(row)"
:title="lockedHint(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="lockedHint(row)"
placeholder="可留空"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
/>
</td>
<!--
行操作区:只对已发布的行出现(草稿行本来就能直接改)。
正常已发布的行并排两个按钮——「修改本条」(退回草稿改一改,改完重新发布)与
「作废本条」(这一条不算了);已作废的行只有一个「重新录入」。
guest / 断线只读时按钮保留但置灰,让访客看得到「这里能改 / 能作废」而不是一片空白。
-->
<td class="center action-cell">
<button
v-for="target in rowActionsOf(row)"
:key="target.action"
class="mx-btn mx-btn-sm action-btn"
:class="target.action === 'void' ? 'mx-btn-danger' : 'mx-btn-secondary'"
type="button"
:disabled="!canWrite || resultActionBusy"
:title="RESULT_ROW_ACTION_TITLE[target.action]"
@click="requestResultAction(target)"
>
{{ RESULT_ROW_ACTION_LABEL[target.action] }}
</button>
<span v-if="rowActionsOf(row).length === 0" class="muted">—</span>
</td>
</tr>
<tr v-if="!loading && visibleRows.length === 0">
<td colspan="12">
<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>
<!--
团体名次:按单位录入,只喂给「团体 + 手动名次」的通道(设计 17 §八第 3 条)。
与上面的成绩表是两套数据,所以单开一块,且不参与成绩表的行选择 / 批次筛选。
-->
<section v-if="showTeamRanks" class="mx-card team-ranks-card" :class="{ 'is-readonly': !canWrite }">
<div class="mx-card-head">
<span class="mx-card-title">团体名次</span>
<div class="team-ranks-actions">
<span v-if="teamRanksLoading" class="team-ranks-hint">读取中…</span>
<span v-if="teamRankDirty" class="mx-badge mx-badge-warning">未保存</span>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!canWrite || teamRanksSaving || !teamRankDirty"
:title="teamRankDirty ? '保存本组团体名次并重算名次与积分' : '没有改动'"
@click="saveTeamRanks"
>
<MxIcon name="check" :size="14" /> {{ teamRanksSaving ? '保存中…' : '保存团体名次' }}
</button>
</div>
</div>
<div class="mx-card-body">
<!--
guest / 断线只读时整块置灰并说明原因:团体名次是写操作,
读得到、改不了,必须让访客知道为什么输不进去。
-->
<div v-if="!canWrite" class="team-ranks-readonly-note">
<MxIcon name="warning" :size="15" />
<span>{{ auth.readOnly ? '当前为访客只读视图,团体名次只能查看,无法录入。' : '服务端未连接,暂时不能录入团体名次。' }}</span>
</div>
<p class="team-ranks-desc">
按<strong>单位</strong>录入的团体名次:只影响本组别的团体通道(名次来源 = 手动录名次的团体通道),
<strong>不影响个人名次</strong>;留空即清空该单位。名次重复允许并列,保存后本组名次与积分会重算。
</p>
<ul class="team-rank-list">
<li v-for="row in teamRankRows" :key="row.teamId" class="team-rank-item">
<span class="team-rank-code mono">{{ row.code }}</span>
<span class="team-rank-name">{{ row.shortName || row.name }}</span>
<input
v-model="row.input"
class="mx-cell-input rank-input mono"
type="number"
min="1"
:max="teamRankRows.length"
:disabled="!canWrite"
:class="{ 'is-invalid': teamRankIssueTeams.has(row.teamId) }"
:placeholder="`1-${teamRankRows.length}`"
:title="teamRankIssueTeams.has(row.teamId) ? '名次重复或超出参赛单位数,请确认' : ''"
/>
</li>
<li v-if="teamRankRows.length === 0" class="team-rank-empty">
没有可录入的参赛单位(单位列表为空或都已停用)
</li>
</ul>
<ul v-if="teamRankIssues.length > 0" class="issue-list team-rank-issues">
<li v-for="(message, index) in teamRankIssues" :key="index" class="warn">
<MxIcon name="bell" :size="14" />
<span>{{ message }}(只提示,不阻止保存)</span>
</li>
</ul>
<div v-if="teamRankRows.length > 0" class="team-ranks-foot">
<span class="team-ranks-hint">共 {{ teamRankRows.length }} 个参赛单位</span>
</div>
</div>
</section>
<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"
/>
<!--
补报名:外壳照页面里既有的写法(RecordsPage 的新增纪录弹窗)自己套一层,
里面的勾选界面直接复用基础数据 → 报名导入页的 EntryPicker,预选当前小项与组别。
做成弹窗而不是第二个页面,是因为补报名就发生在录入过程中间,不该把人从这张表上带走。
-->
<div v-if="supplementOpen" class="mx-modal-overlay open" @click.self="supplementOpen = false">
<div class="mx-modal mx-modal-xl" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<span class="mx-modal-title">补报名</span>
<button class="mx-modal-close" type="button" @click="supplementOpen = false">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<p class="supplement-hint">
给<strong>{{ selectedEvent?.name }}</strong> · <strong>{{ currentCategoryName }}</strong>
补录报名项:勾选的人 / 单位立即加入本组,下面的成绩表会跟着刷新。要补的是别的小项或组别,
在弹窗里改选即可。
</p>
<EntryPicker
:dialog="true"
:preset-event-id="selectedEventId"
:preset-category-id="selectedCategoryId"
@submitted="onSupplementSubmitted"
/>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" @click="supplementOpen = false">关闭</button>
</div>
</div>
</div>
<!--
成员单位与份额(联队,devdocs/19):外壳照页面里既有的弹窗写法自己套一层。
默认摆出来的就是这条报名当前的份额——单单位报名打开后只有一行主单位、权重留空,
不点「添加」「保存」的话一个请求都不会发出去,常规报名流程不受影响。
-->
<div v-if="shareEditor !== null" class="mx-modal-overlay open" @click.self="closeShareEditor">
<div class="mx-modal" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<span class="mx-modal-title">成员单位与份额</span>
<button class="mx-modal-close" type="button" @click="closeShareEditor">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<p class="share-hint">
一条报名可以挂<strong>多个单位</strong>(两个单位因人数不足合并参赛时用):这条成绩的积分按份额
拆给各成员单位,奖牌<strong>各记一枚</strong>;分数平均拆、接受小数(团体第 1 名 15 分 → 两个单位各 7.5 分)。
份额是<strong>相对权重</strong>:都留空就是均分(各 50%),填 2 与 1 就是 67% 与 33%。
</p>
<p class="bd-note">
正在修改:{{ shareEditor.label }};主单位「{{ shareEditor.primaryName }}」不能去掉。
</p>
<!-- 访客 / 断网只读:改不了就说明白原因,别让人对着灰按钮猜。 -->
<div v-if="!canWrite" class="share-readonly">
<MxIcon name="lock" :size="14" />
<span>{{ auth.readOnly ? '当前为访客只读视图,成员单位只能查看。' : '服务端未连接,暂时不能修改成员单位。' }}</span>
</div>
<ul class="share-list">
<li v-for="member in shareEditor.draft" :key="member.teamId" class="share-item">
<span class="share-code mono">{{ member.teamCode || '—' }}</span>
<span class="share-name">{{ member.teamName }}</span>
<span v-if="member.teamId === shareEditor.primaryTeamId" class="mx-badge mx-badge-neutral">主单位</span>
<input
class="mx-cell-input share-weight mono"
type="number"
min="0"
step="0.5"
placeholder="留空=1"
:value="member.weightText"
:disabled="!canWrite || shareEditor.saving"
title="份额是相对权重:留空或填 0 都按 1 算(均分)"
@input="editShareWeight(member.teamId, $event)"
/>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="member.teamId === shareEditor.primaryTeamId || shareEditor.saving"
:title="
member.teamId === shareEditor.primaryTeamId
? '主单位不能去掉:报名挂在它名下,换主单位要重新建报名'
: '把这个单位从这个联队里去掉'
"
@click="dropShareMember(member.teamId)"
>
移除
</button>
</li>
</ul>
<div class="share-add">
<select
v-model="pendingShareTeamId"
class="mx-cell-input bd-select"
:disabled="!canWrite || shareEditor.saving || shareCandidates.length === 0"
>
<option value="">添加成员单位…</option>
<option v-for="team in shareCandidates" :key="team.id" :value="String(team.id)">
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
</option>
</select>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="pendingShareTeamId === '' || shareEditor.saving"
@click="addShareMember"
>
添加
</button>
</div>
<p class="share-ratio">
当前比例:<strong>{{ shareRatioText }}</strong>
<span v-if="shareView.length > 1" class="mx-badge mx-badge-info">{{ jointTeamLabel(shareView) }}</span>
</p>
<ul v-if="shareIssues.length > 0" class="issue-list">
<li v-for="(message, index) in shareIssues" :key="index" class="error">
<MxIcon name="warning" :size="14" />
<span>{{ message }}</span>
</li>
</ul>
<div v-if="shareEditor.error !== null" class="mx-badge mx-badge-error share-error">
{{ shareEditor.error }}
</div>
<p class="bd-note">
保存后本组的名次与积分会当场重算,大屏榜单跟着刷新。没改动的话保存按钮是灰的,
点「关闭」不会发出任何请求。
</p>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" @click="closeShareEditor">
{{ shareChanged ? '放弃修改' : '关闭' }}
</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSaveShares" @click="saveShareEditor">
<MxIcon name="check" :size="14" />
{{ shareEditor.saving ? '保存中…' : '保存成员单位' }}
</button>
</div>
</div>
</div>
<ConfirmDialog
:open="unpublishDialog"
tone="danger"
title="确认撤销发布?"
message="撤销发布将把该组已发布的成绩退回草稿,并作废已产生的积分流水,大屏会回退到发布前的状态。"
:details="[`影响单位:${affectedTeams} 个`, `积分回退:${formatPoints(maxDelta)} 分`]"
confirm-text="撤销发布"
:pending="unpublishing"
@confirm="confirmUnpublish"
@cancel="unpublishDialog = false"
/>
<!--
单条作废 / 重新录入:原因必填(服务端写审计),后果写在弹窗里。
与「撤销发布」分开:这是外科手术式改一条,不动同组其它已发布成绩。
-->
<ConfirmDialog
:open="resultAction !== null"
tone="danger"
:title="resultActionCopy.title"
:message="resultActionCopy.message"
:details="
resultAction === null
? []
: [`${resultAction.resultText === '' ? '(空成绩)' : resultAction.resultText}`, '同组其它人的名次与积分会一起重算并推送大屏']
"
:confirm-text="resultActionCopy.confirmText"
:reason-required="true"
:reason-label="resultActionCopy.reasonLabel"
:pending="resultActionBusy"
@confirm="confirmResultAction"
@cancel="cancelResultAction"
/>
<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="discardDetails"
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>
/* 补报名弹窗的说明行:与弹窗正文同宽,先点名「补的是哪一组」再让人勾选。 */
.supplement-hint {
margin-bottom: 14px;
font-size: 13px;
line-height: 1.6;
color: var(--mx-t2);
}
.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;
}
/* 单位格兼作「成员单位与份额」的入口:悬停给出手型与底色,点开的是同一个弹窗。
文案本身不动——单单位报名的这一格与改造前逐字一样。 */
.team-cell.is-editable {
display: inline-block;
max-width: 100%;
border-radius: var(--mx-r-sm);
cursor: pointer;
}
.team-cell.is-editable:hover {
background: var(--mx-primary-glow);
}
/* 联队:单位名 + 各自份额逐条列出(名字写全,比例看得见),不再压成一行。 */
.team-cell.is-joint {
display: flex;
flex-direction: column;
gap: 2px;
font-weight: 600;
}
.joint-member {
font-weight: 500;
font-size: 12px;
color: var(--mx-t2);
}
/* ── 成员单位与份额弹窗 ── */
.share-hint {
margin-bottom: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--mx-t2);
}
.share-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 10px 0;
}
.share-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.share-code {
min-width: 44px;
font-size: 12px;
color: var(--mx-t3);
}
.share-name {
flex: 1;
min-width: 0;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-weight {
width: 96px;
text-align: center;
}
.share-add {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.share-ratio {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
color: var(--mx-t2);
}
.share-readonly {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 8px;
font-size: 12px;
color: var(--mx-amber);
}
.share-error {
display: block;
margin-bottom: 8px;
padding: 10px 12px;
line-height: 1.5;
}
/*
* 行操作区:已发布的行要并排放「修改本条」+「作废本条」两个按钮,
* 列宽按两者之和留(4 字 ×12px + 内边距 + 单元格 padding),否则按钮会换行把这一行撑高一倍。
* 按钮内边距收窄(12 → 10px)是为了把列宽压住,不与上面的列宽估算脱节。
*/
.action-col,
.action-cell {
width: 180px;
white-space: nowrap;
}
.action-cell .action-btn {
padding: 0 10px;
}
.action-cell .action-btn + .action-btn {
margin-left: 6px;
}
.action-cell .muted {
color: var(--mx-t3);
}
/*
* 团体名次区块:与成绩表分开的一张卡片,横向铺开单位 + 名次输入框。
* 只读(guest / 断线)时整块降透明度,配合卡片内的只读提示说明原因。
*/
.team-ranks-card.is-readonly {
opacity: 0.7;
}
.team-ranks-actions {
display: flex;
align-items: center;
gap: 8px;
}
.team-ranks-readonly-note {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 10px;
border-radius: var(--mx-r-sm);
font-size: 12px;
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
}
.team-ranks-desc {
margin-bottom: 10px;
font-size: 12px;
line-height: 1.6;
color: var(--mx-t3);
}
.team-rank-list {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
}
.team-rank-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--mx-r-sm);
background: var(--mx-s2);
font-size: 12px;
color: var(--mx-t2);
}
.team-rank-code {
color: var(--mx-t3);
}
.team-rank-name {
min-width: 72px;
}
.team-rank-empty {
font-size: 12px;
color: var(--mx-t3);
}
.team-rank-issues {
margin-top: 10px;
}
.team-ranks-foot {
margin-top: 10px;
}
.team-ranks-hint {
font-size: 12px;
color: var(--mx-t3);
}
.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;
}
/*
* 已发布行的可点格子(成绩格 / 状态格):从「彻底禁用」改成「点一下退回草稿改」。
* 底色与草稿格(--mx-s3)不同 + 常态描边 + cursor: pointer,一眼看出这里点得动;
* 点下去只是弹与「修改本条」同一个确认框,不确认则什么都不会发生。
* 已作废行的格子 cellAction 为 null:拿不到这个类,也仍走 disabled(保持原来的禁用观感)。
*/
.is-locked-cell {
cursor: pointer;
text-align: left;
background: var(--mx-s2);
border-color: var(--mx-border);
}
.is-locked-cell:hover:not(:disabled) {
border-color: var(--mx-primary);
box-shadow: 0 0 0 3px var(--mx-primary-glow);
}
.is-locked-cell:disabled {
cursor: not-allowed;
}
.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>