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