Files
Scoreboard/web/src/admin/pages/EntryPage.vue
T
AskaEth 6bd7bd1627 feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名
口径(用户确认):
- 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数)
- 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分
- 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、
  没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存

服务端:
- 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team;
  events 加 avg_mode(默认 valid)
- 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team →
  默认 athlete(result, 不计入单位) + team(aggregate 队内平均)
- 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名
  (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与)

前端:
- 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」
- 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存
- 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致)
- 大屏/管理端团体成绩只显示数值,不出现「平均」字样

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
2026-09-10 12:02:00 +08:00

1212 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
eventInputOf,
formatPoints,
hasAggregateChannel,
parseResultValue,
type AvgMode,
type DraftResultInput,
type EntryWithResult,
type Event,
type ResultState,
type ResultStatus,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode, type AvgModeState } from './avgMode'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import { entryRowLabel } from '../components/baseData/entrySelection'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/** 表格里的一行:报名项 + 正在编辑的成绩。 */
interface EditableRow {
entryId: number
categoryId: number
bib: string | null
teamId: number
athleteId: number | null
teamName: string
teamShort: string
athleteName: string
memberNames: string[] | null
resultText: string
rank: number | null
status: ResultStatus
isTie: boolean
note: string
resultState: ResultState | null
original: string
}
interface Issue {
entryId: number | null
level: 'error' | 'warn'
message: string
}
interface PreviewRow {
teamId: number
name: string
current: number
delta: number
next: number
}
const STATUS_OPTIONS: Array<{ value: ResultStatus; label: string }> = [
{ value: 'ok', label: '有效' },
{ value: 'dns', label: 'DNS 弃赛' },
{ value: 'dq', label: 'DQ 取消' },
{ value: 'dnf', label: 'DNF 未完赛' },
{ value: 'withdrawn', label: '退赛' },
]
const route = useRoute()
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const selectedEventId = ref<number | null>(null)
const detail = ref<Event | null>(null)
const selectedCategoryId = ref<number | null>(null)
const allRows = ref<EditableRow[]>([])
const loading = ref(false)
const saving = ref(false)
const publishing = ref(false)
const unpublishing = ref(false)
const publishDialog = ref(false)
const unpublishDialog = ref(false)
const focusedEntryId = ref<number | null>(null)
const tableRef = ref<HTMLElement | null>(null)
const recordForEvent = ref<{ resultText: string; resultValue: number } | null>(null)
/** 项目切换的请求序号:只有最新一次请求的响应才允许写入页面状态。 */
let requestSeq = 0
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const eventGroups = computed(() => {
const groups = data.sports
.map((sport) => ({ key: sport.id, name: sport.name, events: data.events.filter((e) => e.sportId === sport.id) }))
.filter((group) => group.events.length > 0)
if (groups.length > 0) return groups
return [{ key: 0, name: '全部项目', events: data.events }]
})
const selectedEvent = computed(() => detail.value ?? (selectedEventId.value === null ? null : (data.eventById(selectedEventId.value) ?? null)))
const categories = computed(() => detail.value?.categories ?? [])
const channels = computed(() => detail.value?.channels ?? [])
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
const manualRank = computed(() => selectedEvent.value?.resultMode === 'manual' || selectedEvent.value?.compare === 'none')
const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
/** 只有配了「按队内平均」通道的小项才需要选平均口径。 */
const showAvgMode = computed(() => hasAggregateChannel(channels.value))
function unitOf(type: Event['resultType']): string {
if (type === 'time') return 's'
if (type === 'distance') return 'm'
if (type === 'count') return '次'
if (type === 'score') return 'pts'
return ''
}
const unit = computed(() => unitOf(selectedEvent.value?.resultType ?? 'none'))
const visibleRows = computed(() =>
allRows.value.filter((row) => selectedCategoryId.value === null || row.categoryId === selectedCategoryId.value),
)
const scorable = computed<ScorableRow[]>(() =>
visibleRows.value.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,
})),
)
const score = computed(() => {
const event = selectedEvent.value
if (event === null) return null
return computeEventScore(scorable.value, {
event,
category: categories.value.find((item) => item.id === selectedCategoryId.value) ?? null,
channels: channels.value,
profiles: data.profiles,
weight: weight.value,
})
})
/**
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
*
* 用 rowTeamPoints(各通道原始分,个人通道已按权重折算)而不是服务端 result.points:
* 后者只覆盖已发布行,且编辑中的行服务端还没算分,两边口径无法混用。
*/
const publishedContribution = computed(() => {
const map = new Map<number, number>()
const rowTeamPoints = score.value?.rowTeamPoints
for (const row of visibleRows.value) {
if (row.resultState !== 'published') continue
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (rowTeamPoints?.get(row.entryId) ?? 0))
}
return map
})
const preview = computed<PreviewRow[]>(() => {
const current = new Map(data.standings.map((row) => [row.teamId, row.totalPoints]))
const next = score.value?.teamPoints ?? new Map<number, number>()
return data.teams
.map((team) => {
const now = current.get(team.id) ?? 0
const delta = round((next.get(team.id) ?? 0) - (publishedContribution.value.get(team.id) ?? 0))
return { teamId: team.id, name: team.shortName ?? team.name, current: now, delta, next: round(now + delta) }
})
.sort((a, b) => b.next - a.next || a.name.localeCompare(b.name))
})
const maxDelta = computed(() => preview.value.reduce((max, row) => Math.max(max, Math.abs(row.delta)), 0))
const affectedTeams = computed(() => preview.value.filter((row) => row.delta !== 0).length)
const isPublished = computed(() => visibleRows.value.some((row) => row.resultState === 'published'))
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
/** 已发布的行由服务端锁定:只能先撤销发布,不能就地改草稿。 */
function rowLocked(row: EditableRow): boolean {
return row.resultState === 'published'
}
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) {
if (row.status !== 'ok') continue
const needsRank = manualRank.value
if (row.resultText.trim() === '') {
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
if (!(needsRank && row.rank !== null)) {
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:缺少成绩` })
}
} else {
const value = parseResultValue(row.resultText, unit.value)
if (value === null) {
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:成绩格式无法解析` })
} else if (value <= 0) {
list.push({ entryId: row.entryId, level: 'warn', message: `${rowLabel(row)}:成绩 ≤ 0,疑似录入错误` })
}
}
if (needsRank && row.rank === null) {
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:缺少名次` })
}
if (needsRank && 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 row of visibleRows.value) {
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: `${rowLabel(row)}:${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'))
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
function rowLabel(row: EditableRow): string {
return entryRowLabel(row)
}
function round(value: number): number {
return Number(value.toFixed(2))
}
/** 空缺成绩的快照必须与 rowDirty 拼出的默认行一致,否则一进页面就亮「已修改」。 */
function snapshotOf(entry: EntryWithResult): string {
const result = entry.result
if (result === null) return ['', '', 'ok', '0', ''].join('|')
return [result.resultText, result.rank ?? '', result.status, result.isTie ? '1' : '0', result.note ?? ''].join('|')
}
function toRow(entry: EntryWithResult): EditableRow {
const result = entry.result
const row: EditableRow = {
entryId: entry.id,
categoryId: entry.categoryId,
bib: entry.bib,
teamId: entry.teamId,
athleteId: entry.athleteId,
teamName: entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位',
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
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 confirmDiscard(): boolean {
if (!dirty.value) return true
return window.confirm('当前有未保存的成绩修改,切换项目会丢弃这些修改。确定继续吗?')
}
async function selectEvent(id: number): Promise<void> {
if (selectedEventId.value === id && detail.value !== null) return
if (!confirmDiscard()) return
selectedEventId.value = id
const seq = (requestSeq += 1)
loading.value = true
try {
const [event, entries, records] = await Promise.all([
adminApi.eventDetail(id),
adminApi.entries(id),
adminApi.records(),
])
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
if (seq !== requestSeq) return
detail.value = event
avgMode.value = loadAvgMode(id, event.avgMode)
selectedCategoryId.value = event.categories?.[0]?.id ?? null
allRows.value = entries.map(toRow)
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
recordForEvent.value = record === null ? null : { resultText: record.resultText, resultValue: record.resultValue }
} catch (caught) {
if (seq !== requestSeq) return
toast.error(caught instanceof Error ? caught.message : '加载项目失败')
} finally {
if (seq === requestSeq) loading.value = false
}
}
/**
* 切换平均值口径:立即整体回写该小项(PUT /api/events/{id}),成功后 toast。
*
* 与成绩草稿的保存完全分离——这里既不改录入行,也不走 saveDraft;
* 失败时把选择退回原值,避免页面显示的口径与服务端实际生效的口径不一致。
*/
async function pickAvgMode(mode: AvgMode): Promise<void> {
const event = detail.value
if (event === null || !canWrite.value) return
if (data.isMock) {
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报;与「发布」一致直接停用。
toast.error('示例数据模式不能保存口径', '后端未就绪,改动不会写入真实赛事')
return
}
const next = selectAvgMode(avgMode.value, mode)
avgMode.value = next.state
if (!next.shouldSave) return
try {
const saved = await adminApi.client.events.update(event.id, eventInputOf(event, { avgMode: mode }))
avgMode.value = settleAvgMode(avgMode.value, true)
// 服务端返回的是小项本身,组别 / 通道仍以当前页加载的详情为准。
detail.value = {
...saved,
categories: saved.categories ?? event.categories,
channels: saved.channels ?? event.channels,
}
await data.refreshEvents()
toast.success(
'平均值口径已保存',
mode === 'all' ? '没成绩的按 0 计入平均' : '只算有有效成绩的人',
)
} catch (caught) {
avgMode.value = settleAvgMode(avgMode.value, false)
toast.error('保存平均值口径失败', caught instanceof Error ? caught.message : null)
}
}
async function reload(): Promise<void> {
if (selectedEventId.value === null) return
const id = selectedEventId.value
const seq = requestSeq
loading.value = true
try {
const entries = await adminApi.entries(id)
if (seq !== requestSeq) return
allRows.value = entries.map(toRow)
} catch (caught) {
if (seq !== requestSeq) return
toast.error(caught instanceof Error ? caught.message : '重新加载失败')
} finally {
if (seq === requestSeq) loading.value = false
}
}
function buildDraftPayload(): DraftResultInput[] {
return visibleRows.value.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 (isPublished.value) {
// 已发布行由服务端锁定:就地存草稿会清掉名次却不重算积分,必须先撤销发布。
toast.error('该组已发布', '修改前请先撤销发布')
return false
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return false
}
saving.value = true
try {
await adminApi.saveDraft({
eventId: selectedEventId.value,
categoryId: selectedCategoryId.value ?? undefined,
results: buildDraftPayload(),
})
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 (isPublished.value) {
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
try {
// 发布作用于服务端已保存的草稿,所以必须先整组落草稿再发布(07 第 3 节)。
await adminApi.saveDraft({
eventId: selectedEventId.value,
categoryId: selectedCategoryId.value ?? undefined,
results: buildDraftPayload(),
})
await adminApi.publish({
eventId: selectedEventId.value,
categoryId: selectedCategoryId.value ?? undefined,
})
publishDialog.value = false
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
toast.success('发布成功,积分榜已更新')
} catch (caught) {
toast.error('发布失败', caught instanceof Error ? caught.message : null)
} finally {
publishing.value = false
}
}
async function confirmUnpublish(): Promise<void> {
if (selectedEventId.value === null) return
unpublishing.value = true
try {
await adminApi.unpublish({
eventId: selectedEventId.value,
categoryId: selectedCategoryId.value ?? undefined,
})
unpublishDialog.value = false
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
toast.warning('已撤销发布,大屏将回退该组成绩')
} catch (caught) {
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
} finally {
unpublishing.value = false
}
}
function autoSort(): void {
const direction = selectedEvent.value?.compare === 'asc' ? 1 : -1
allRows.value = [...allRows.value].sort((a, b) => {
const av = parseResultValue(a.resultText, unit.value)
const bv = parseResultValue(b.resultText, unit.value)
if (av === null && bv === null) return 0
if (av === null) return 1
if (bv === null) return -1
return direction * (av - bv)
})
toast.info('已按成绩重排(名次仍以比较值为准)')
}
/** Enter 在单元格之间移动;Tab 交给浏览器原生行为。 */
function onCellKeydown(event: KeyboardEvent, element: HTMLElement): void {
if (event.key !== 'Enter' || event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) return
event.preventDefault()
const cells = Array.from(tableRef.value?.querySelectorAll<HTMLElement>('[data-cell]') ?? [])
const index = cells.indexOf(element)
const next = cells[index + 1]
if (next !== undefined) {
next.focus()
if (next instanceof HTMLInputElement) next.select()
}
}
function onPageKeydown(event: KeyboardEvent): void {
if (!event.ctrlKey && !event.altKey) return
if (event.key !== 'Enter') return
event.preventDefault()
if (saving.value || publishing.value || unpublishing.value) return
if (event.altKey) requestPublish()
else void saveDraft()
}
/** 关闭/刷新页面前拦住未保存的编辑,避免一整组成绩静默丢失。 */
function onBeforeUnload(event: BeforeUnloadEvent): void {
if (!dirty.value) return
event.preventDefault()
event.returnValue = ''
}
/**
* Excel 粘贴:两列及以上时,第一列按单位名匹配行,最后一列作为成绩;
* 单列时从当前行往下顺序填充(07 第 2 节第 4 条)。
*/
function onPaste(event: ClipboardEvent, startEntryId: number): void {
const text = event.clipboardData?.getData('text/plain') ?? ''
if (text.trim() === '') return
event.preventDefault()
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
let cursor = visibleRows.value.findIndex((row) => row.entryId === startEntryId)
for (const line of lines) {
const columns = line.split('\t').map((column) => column.trim())
if (cursor < 0 || cursor >= visibleRows.value.length) break
let target = visibleRows.value[cursor]
if (target === undefined) break
if (columns.length >= 2) {
const key = columns[0] ?? ''
const matched = visibleRows.value.find(
(row) => row.teamName.includes(key) || (row.teamShort !== '' && key.includes(row.teamShort)),
)
if (matched !== undefined) target = matched
target.resultText = columns[columns.length - 1] ?? ''
} else {
target.resultText = columns[0] ?? ''
}
cursor = visibleRows.value.indexOf(target) + 1
}
toast.info(`已粘贴 ${lines.length} 行`)
}
function moveFocusBy(delta: number): void {
const cells = Array.from(tableRef.value?.querySelectorAll<HTMLElement>('[data-cell]') ?? [])
if (cells.length === 0) return
const active = document.activeElement
const index = active instanceof HTMLElement ? cells.indexOf(active) : -1
const next = cells[Math.min(Math.max(index + delta, 0), cells.length - 1)]
next?.focus()
}
/** 默认聚焦上次编辑的项目(07 第 7 节);URL 带 eventId 时优先。 */
async function pickInitialEvent(): Promise<void> {
if (selectedEventId.value !== null) return
const query = route.query.eventId
const fromQuery = typeof query === 'string' ? Number(query) : Number.NaN
if (Number.isFinite(fromQuery) && data.eventById(fromQuery) !== undefined) {
await selectEvent(fromQuery)
return
}
const fallback = data.events.find((event) => event.status === 'pending') ?? data.events[0]
if (fallback !== undefined) await selectEvent(fallback.id)
}
onMounted(() => {
window.addEventListener('beforeunload', onBeforeUnload)
void pickInitialEvent()
})
onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
})
// 项目列表由布局层异步加载,加载完成后补一次默认选中。
watch(
() => data.loaded,
(loaded) => {
if (loaded) void pickInitialEvent()
},
)
</script>
<template>
<div class="entry-page" @keydown="onPageKeydown">
<PageHeader title="成绩录入" :subtitle="selectedEvent?.name ?? '选择左侧项目开始录入'">
<template #actions>
<span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || saving || isPublished"
:title="isPublished ? '该组已发布,请先撤销发布' : ''"
@click="saveDraft()"
>
<MxIcon name="check" :size="15" /> 存草稿
</button>
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || publishing || data.isMock || isPublished"
@click="requestPublish"
>
<MxIcon name="push" :size="15" /> 发布
</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="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="selectedCategoryId = category.id"
>
{{ category.name }}
</button>
</div>
<div class="toolbar-right">
<span v-if="selectedEvent.resultType !== 'none'" class="unit-hint mono">单位:{{ unit || '—' }}</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="autoSort">
<MxIcon name="adjust" :size="14" /> 按成绩重排
</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="moveFocusBy(1)">
<MxIcon name="keyboard" :size="14" /> 下一格
</button>
<button
v-if="isPublished"
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!canWrite"
@click="unpublishDialog = true"
>
撤销发布
</button>
</div>
</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="isPublished" class="published-note">
<MxIcon name="lock" :size="15" />
该组已发布,成绩与名次已锁定上屏:如需修改,请先「撤销发布」。草稿态不会上屏。
</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>号码</th>
<th>单位</th>
<th>运动员 / 队</th>
<th>成绩输入</th>
<th class="center">名次</th>
<th>状态</th>
<th class="center">积分(自动)</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in visibleRows"
:key="row.entryId"
:class="{ 'is-dirty': rowDirty(row), 'is-focus': focusedEntryId === row.entryId }"
>
<td class="mono">{{ row.bib ?? '—' }}</td>
<td>
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
</td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
<td>
<input
v-model="row.resultText"
data-cell
class="mx-cell-input mono"
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error' }"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
:placeholder="unit === 's' ? '1:06.33' : unit"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
@paste="onPaste($event, row.entryId)"
/>
</td>
<td class="center">
<input
v-if="manualRank"
:value="row.rank ?? ''"
data-cell
type="number"
min="1"
class="mx-cell-input rank-input"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
@input="row.rank = rankInputValue($event)"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
/>
<span v-else class="rank-auto mono">{{ rankOf(row) ?? '—' }}</span>
</td>
<td>
<select
v-model="row.status"
data-cell
class="mx-cell-input status-select"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
>
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</td>
<td class="center">
<span class="points-cell mono" :class="{ zero: pointsOf(row) === 0 }">
{{ formatPoints(pointsOf(row)) }}
</span>
</td>
<td>
<input
v-model="row.note"
data-cell
class="mx-cell-input"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
placeholder="—"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
/>
</td>
</tr>
<tr v-if="!loading && visibleRows.length === 0">
<td colspan="8">
<div class="mx-empty">
<div class="mx-empty-title">该组还没有报名项</div>
<div class="mx-empty-desc">请先导入报名表或在基础数据里添加</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<aside class="mx-card preview-card">
<div class="mx-card-head">
<span class="mx-card-title">实时积分预览</span>
<span class="mx-badge mx-badge-info">{{ affectedTeams }} 个单位受影响</span>
</div>
<div class="mx-card-body">
<p class="preview-hint">
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
</p>
<ul class="preview-list">
<li v-for="row in preview" :key="row.teamId" class="preview-item">
<span class="preview-name">{{ row.name }}</span>
<span class="preview-delta mono" :class="row.delta > 0 ? 'up' : row.delta < 0 ? 'down' : ''">
{{ row.delta > 0 ? '+' : '' }}{{ formatPoints(row.delta) }}
</span>
<span class="preview-total mono">{{ formatPoints(row.next) }}</span>
</li>
</ul>
</div>
</aside>
</div>
<div v-if="issues.length > 0" class="mx-card issues-card">
<div class="mx-card-head">
<span class="mx-card-title">防呆校验</span>
<span class="issue-counts">
<span v-if="errors.length > 0" class="mx-badge mx-badge-error">{{ errors.length }} 项阻止发布</span>
<span v-if="warnings.length > 0" class="mx-badge mx-badge-warning">{{ warnings.length }} 项提醒</span>
</span>
</div>
<div class="mx-card-body">
<ul class="issue-list">
<li v-for="(issue, index) in issues" :key="index" :class="issue.level">
<MxIcon :name="issue.level === 'error' ? 'warning' : 'bell'" :size="14" />
<span>{{ issue.message }}</span>
</li>
</ul>
</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="`本次将影响 ${affectedTeams} 个单位,最大分差变化 ${formatPoints(maxDelta)} 分。发布后立即上屏并重算积分榜。`"
:details="warnings.map((issue) => issue.message)"
confirm-text="确认发布"
:pending="publishing"
@confirm="confirmPublish"
@cancel="publishDialog = false"
/>
<ConfirmDialog
:open="unpublishDialog"
tone="danger"
title="确认撤销发布?"
message="撤销发布将作废该组已产生的积分流水,大屏会回退到发布前的状态。"
:details="[`影响单位:${affectedTeams} 个`, `积分回退:${formatPoints(maxDelta)} 分`]"
confirm-text="撤销发布"
:pending="unpublishing"
@confirm="confirmUnpublish"
@cancel="unpublishDialog = false"
/>
</div>
</template>
<style scoped>
.mock-note {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 14px;
border-radius: var(--mx-r-sm);
font-size: 13px;
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
}
.entry-grid {
display: grid;
grid-template-columns: 264px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.project-panel {
position: sticky;
top: 0;
max-height: calc(100dvh - 140px);
display: flex;
flex-direction: column;
}
.project-list {
overflow-y: auto;
padding: 8px;
}
.project-group + .project-group {
margin-top: 6px;
}
.project-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 9px 10px;
border-radius: var(--mx-r-sm);
cursor: pointer;
color: var(--mx-t2);
font-size: 13px;
text-align: left;
transition: all var(--mx-d1) var(--mx-ease);
}
.project-item:hover {
background: var(--mx-s-hover);
color: var(--mx-text);
}
.project-item.active {
background: var(--mx-primary-glow);
color: var(--mx-primary);
font-weight: 600;
}
.project-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.entry-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.unit-hint {
font-size: 12px;
color: var(--mx-t3);
}
.avg-mode-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 16px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
border: 1px solid var(--mx-primary);
background: var(--mx-primary-glow);
}
.avg-mode-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--mx-primary);
}
.avg-mode-option {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
color: var(--mx-t2);
cursor: pointer;
}
.avg-mode-option.active .avg-mode-text {
color: var(--mx-primary);
font-weight: 600;
}
.avg-mode-hint {
font-size: 12px;
color: var(--mx-t3);
}
.avg-mode-note {
margin-left: auto;
font-size: 12px;
color: var(--mx-t3);
}
.published-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(52, 211, 153, 0.08);
color: var(--mx-green);
font-size: 13px;
}
.entry-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 14px;
align-items: start;
}
.table-card {
min-width: 0;
}
.table-card .mx-table-wrap {
max-height: calc(100dvh - 320px);
overflow: auto;
}
.team-cell {
font-weight: 600;
}
.center {
text-align: center;
}
.rank-input {
width: 72px;
text-align: center;
}
.status-select {
width: 110px;
}
.rank-auto {
font-size: 14px;
font-weight: 700;
color: var(--mx-primary);
}
.points-cell {
font-size: 15px;
font-weight: 800;
color: var(--mx-green);
}
.points-cell.zero {
color: var(--mx-t3);
font-weight: 600;
}
.preview-card {
position: sticky;
top: 0;
}
.preview-hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
margin-bottom: 12px;
}
.preview-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-item {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
font-size: 13px;
}
.preview-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-delta {
font-weight: 700;
font-size: 12px;
color: var(--mx-t3);
min-width: 42px;
text-align: right;
}
.preview-delta.up {
color: var(--mx-green);
}
.preview-delta.down {
color: var(--mx-red);
}
.preview-total {
font-weight: 800;
min-width: 46px;
text-align: right;
}
.issues-card .mx-card-body {
padding-top: 12px;
}
.issue-counts {
display: flex;
gap: 6px;
}
.issue-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.issue-list li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
line-height: 1.5;
}
.issue-list li.error {
color: var(--mx-red);
}
.issue-list li.warn {
color: var(--mx-amber);
}
.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>