feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)

用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

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

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

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
AskaEth committed 2026-09-11 17:14:02 +08:00
1 parent 8dd599bd97
commit 631c8dd8e1
80 files changed
+12468 -519

No files matched your search

+474 -13
View File
@@ -8,6 +8,7 @@ import {
parseResultValue,
type AvgMode,
type DraftResultInput,
type EntryStatus,
type EntryWithResult,
type Event,
type ResultState,
@@ -26,6 +27,17 @@ import {
type EntryRowJudgement,
type ResultCellHint,
} from './entryGuards'
import { entryProgress } from './entryProgress'
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
import {
entryBatchOutcome,
entryBatchSizeError,
planEntryBatch,
submitEntryBatch,
type EntryBatchAction,
type EntryBatchPlan,
type EntryBatchRow,
} from './entryBatch'
import {
DEFAULT_HEAT_SIZE,
MAX_HEAT,
@@ -41,6 +53,7 @@ import {
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import { entryRowLabel } from '../components/baseData/entrySelection'
import { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
@@ -58,6 +71,8 @@ interface EditableRow {
memberNames: string[] | null
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
heat: number | null
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
entryStatus: EntryStatus
resultText: string
rank: number | null
status: ResultStatus
@@ -105,6 +120,10 @@ const publishing = ref(false)
const unpublishing = ref(false)
const publishDialog = ref(false)
const unpublishDialog = ref(false)
/** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */
const settlement = ref<SettlementStatus | null>(null)
const settling = ref(false)
const settleDialog = ref(false)
const focusedEntryId = ref<number | null>(null)
/** 成绩格专属的聚焦标记:悬浮提示只跟着它走,别让名次 / 状态格也弹成绩提示。 */
const focusedResultId = ref<number | null>(null)
@@ -238,8 +257,17 @@ const splitPlan = computed(() => planHeatSplit(visibleRows.value, heatSize.value
const scopeWord = computed(() => (selectedHeat.value === 'all' ? '该组' : '当前批次'))
const heatScoped = computed(() => selectedHeat.value !== 'all')
/**
* 报名项是否参与排名:与服务端 domain.EntryStatus.rankable() 同口径——
* 只有 registered 才计分,退赛 / dq / dns 一律 0 分且不占名次。
* 本地预览若不按这条过滤,退赛行会继续显示原来的名次与分数,和榜单对不上。
*/
function rankableRow(row: EditableRow): boolean {
return row.entryStatus === 'registered'
}
function scorableOf(rows: EditableRow[]): ScorableRow[] {
return rows.map((row) => ({
return rows.filter(rankableRow).map((row) => ({
entryId: row.entryId,
teamId: row.teamId,
athleteId: row.athleteId,
@@ -310,6 +338,22 @@ const allPublished = computed(
() => visibleRows.value.length > 0 && visibleRows.value.every((row) => row.resultState === 'published'),
)
const publishedCount = computed(() => visibleRows.value.filter((row) => row.resultState === 'published').length)
/**
* 当前 (小项, 组别) 的未结算状态:非 null 才提示。
*
* 延迟结算关闭时服务端返回空清单,这条提示天然不出现;读不到状态(示例数据 / 断线)时也不提示,
* 辅助信息不该打断现场录入。
*/
const pendingSettlement = computed<SettlementGroup | null>(() => {
if (selectedEventId.value === null || selectedCategoryId.value === null) return null
const groups = settlement.value?.groups ?? []
return (
groups.find(
(group) => group.eventId === selectedEventId.value && group.categoryId === selectedCategoryId.value,
) ?? null
)
})
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
@@ -456,6 +500,19 @@ const stateCounts = computed(() => {
return counts
})
/**
* 批次录入进度:分母是当前筛选范围的行数(选中某一批就只数这一批),
* 「已录」判据与发布防呆 / 行徽标共用 entryProgress → judgeEntryRow,不另写一套解析。
*/
const entryBatchProgress = computed(() =>
entryProgress({
rows: categoryRows.value,
filter: selectedHeat.value,
unit: unit.value,
needsRank: manualRank.value,
}),
)
/** 本次发布真正会带上屏的行:未发布且已填(或已录状态)。 */
const pendingRows = computed(() =>
visibleRows.value.filter((row) => row.resultState !== 'published' && rowJudgement(row)?.state === 'filled'),
@@ -529,7 +586,12 @@ const publishMessage = computed(() => {
const scope = heatScoped.value
? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。'
: ''
return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。发布后立即上屏并重算积分榜。`
// 延迟结算下「发布即重算积分榜」不再成立,确认框不能再说反话。
const tail =
settlement.value?.liveOnPublish === false
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后再更新。'
: '发布后立即上屏并重算积分榜。'
return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。${tail}`
})
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
@@ -567,6 +629,7 @@ function toRow(entry: EntryWithResult): EditableRow {
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
heat: entry.heat,
entryStatus: entry.status,
resultText: result?.resultText ?? '',
rank: result?.rank ?? null,
status: result?.status ?? 'ok',
@@ -805,12 +868,16 @@ async function confirmPublish(): Promise<void> {
categoryId: selectedCategoryId.value ?? undefined,
})
publishDialog.value = false
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
toast.success(
'发布成功,积分榜已更新',
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null,
)
await reloadAfterSettlementChange()
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
const deferred = settlement.value?.liveOnPublish === false
const leftover =
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null
if (deferred && pendingSettlement.value !== null) {
toast.success('已发布本批成绩', `延迟结算:名次与榜单等该组全部录完后再更新${leftover === null ? '' : `;${leftover}`}`)
} else {
toast.success('发布成功,积分榜已更新', leftover)
}
} catch (caught) {
toast.error('发布失败', caught instanceof Error ? caught.message : null)
} finally {
@@ -827,8 +894,7 @@ async function confirmUnpublish(): Promise<void> {
categoryId: selectedCategoryId.value ?? undefined,
})
unpublishDialog.value = false
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
await reloadAfterSettlementChange()
toast.warning('已撤销发布,大屏将回退该组成绩')
} catch (caught) {
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
@@ -837,6 +903,49 @@ async function confirmUnpublish(): Promise<void> {
}
}
// ── 分批结算(延迟结算下的「榜单还没反映这批」提示 + 人工兜底) ────────────────
/** 读当前 (小项, 组别) 的结算状态;失败时按「没有提示」处理,不打断录入。 */
async function refreshSettlement(): Promise<void> {
const id = selectedEventId.value
const categoryId = selectedCategoryId.value
if (id === null) {
settlement.value = null
return
}
try {
const status = await settlementApi.status(id, categoryId)
// 请求期间可能已经切了项目 / 组别,旧响应不许覆盖新状态。
if (id !== selectedEventId.value || categoryId !== selectedCategoryId.value) return
settlement.value = status
} catch {
settlement.value = null
}
}
/** 发布 / 撤销 / 结算之后一起刷新:榜单、项目列表与结算状态。 */
async function reloadAfterSettlementChange(): Promise<void> {
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
await refreshSettlement()
}
/** 立即结算:这是「有人永远录不出成绩」时的人工兜底,成功后榜单按已发布部分更新。 */
async function confirmSettle(): Promise<void> {
if (selectedEventId.value === null) return
settling.value = true
try {
await settlementApi.settle(selectedEventId.value, selectedCategoryId.value ?? undefined)
settleDialog.value = false
await reloadAfterSettlementChange()
toast.success('已按当前已发布成绩结算榜单', '未录完的人仍可继续补录,录完后会再自动结算')
} catch (caught) {
toast.error('立即结算失败', caught instanceof Error ? caught.message : null)
} finally {
settling.value = false
}
}
// ── 批次(录入 / 展示分组,不参与算分) ──────────────────────────────────────
/** 批量设置批次的共同前置检查;不通过时给出具体原因。 */
@@ -919,6 +1028,146 @@ function clearSelection(): void {
selectedEntryIds.value = []
}
// ── 批量退赛 / 恢复 / 移出报名(作用范围 = 勾选行) ────────────────────────────
/** 批量操作的确认框状态;计划由 planEntryBatch 先算好,确认框只负责展示。 */
const batchPlan = ref<EntryBatchPlan | null>(null)
const batchDialog = ref(false)
const batchBusy = ref(false)
/** 勾选行里已退赛的人数:给「批量恢复」按钮的可用性与文案做依据。 */
const selectedScratchedCount = computed(
() => selectedRows.value.filter((row) => row.entryStatus === 'scratched').length,
)
/** 勾选行里非正常参赛的人数(退赛 / dq / dns),决定「批量退赛」是否还有意义。 */
const selectedUnrankableCount = computed(
() => selectedRows.value.filter((row) => !rankableRow(row)).length,
)
/** 勾选行的报名项状态文案;徽标与确认框共用。 */
const ENTRY_STATUS_LABEL: Record<EntryStatus, string> = {
registered: '正常参赛',
scratched: '已退赛',
dq: '取消资格',
dns: '未出场',
}
function entryStatusTitle(status: EntryStatus): string {
switch (status) {
case 'scratched':
return '已退赛(弃权):0 分且不占名次,可用「批量恢复」撤回'
case 'dq':
return '已被判罚取消资格:0 分且不占名次'
case 'dns':
return '已标记未出场:0 分且不占名次'
}
return '正常参赛'
}
function batchRows(): EntryBatchRow[] {
return selectedRows.value.map((row) => ({
entryId: row.entryId,
name: rowLabel(row),
bib: row.bib,
team: row.teamShort || row.teamName,
categoryId: row.categoryId,
entryStatus: row.entryStatus,
}))
}
/** 批量操作的共同前置检查;不通过时给出具体原因。 */
function canBatch(): boolean {
if (data.isMock) {
toast.error('示例数据模式不能批量操作', '后端未就绪,改动不会写入真实赛事')
return false
}
if (!canWrite.value) {
toast.error('当前不可批量操作', auth.readOnly ? '请先登录' : '服务端未连接')
return false
}
// 批量操作会重算并整表刷新,未保存的成绩修改会被覆盖,必须先落草稿。
if (dirty.value) {
toast.error('有未保存的成绩修改', '批量操作会重算并刷新整表,请先「存草稿」再操作')
return false
}
return true
}
/** 点批量按钮:先算清「动谁、跳过谁、重算几个组别」,再让操作员确认。 */
function requestBatchAction(action: EntryBatchAction): void {
if (!canBatch()) return
const rows = batchRows()
const sizeError = entryBatchSizeError(rows.length)
if (sizeError !== null) {
toast.error('批量操作未提交', sizeError)
return
}
const plan = planEntryBatch(action, rows)
if (plan.changed === 0) {
const first = plan.skipped[0]
toast.info(
'没有可处理的行',
first === undefined ? null : `${first.name}:${first.note}`,
)
return
}
batchPlan.value = plan
batchDialog.value = true
}
function cancelBatchAction(): void {
batchDialog.value = false
batchPlan.value = null
}
async function confirmBatchAction(): Promise<void> {
const plan = batchPlan.value
if (plan === null || batchBusy.value) return
batchBusy.value = true
try {
const result = await submitEntryBatch({ action: plan.action, ids: plan.ids })
batchDialog.value = false
batchPlan.value = null
// 整表重载:状态、名次与积分都由服务端重算后的口径决定,不能只在本地的行上打补丁。
await reload()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
clearSelection()
const failed = result.items.filter((item) => !item.ok)
if (failed.length > 0) {
// 部分失败必须逐条说清,不能只报一句「批量操作完成」。
toast.warning(
`批量操作完成(${failed.length} 条未处理)`,
failed.map((item) => `${item.id}:${item.error ?? '失败'}`).join(';'),
)
} else {
toast.success(BATCH_SUCCESS_TITLE[plan.action], entryBatchOutcome(result))
}
} catch (caught) {
toast.error('批量操作失败', caught instanceof Error ? caught.message : null)
} finally {
batchBusy.value = false
}
}
const BATCH_SUCCESS_TITLE: Record<EntryBatchAction, string> = {
scratch: '已批量退赛,积分榜已重算',
restore: '已批量恢复,积分榜已重算',
remove: '已移出报名,积分榜已重算',
}
const BATCH_CONFIRM_TITLE: Record<EntryBatchAction, string> = {
scratch: '确认批量退赛?',
restore: '确认批量恢复?',
remove: '确认移出报名?此操作不可恢复',
}
const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
scratch: '确认退赛',
restore: '确认恢复',
remove: '确认移出',
}
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
function requestSplit(): void {
const plan = splitPlan.value
@@ -1054,6 +1303,9 @@ watch(
if (loaded) void pickInitialEvent()
},
)
// 换项目 / 换组别后结算状态跟着变;提示是辅助信息,读失败不影响录入。
watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { immediate: true })
</script>
<template>
@@ -1175,6 +1427,43 @@ watch(
</div>
</div>
<!--
批次录入进度:贴在工具栏正下方、横跨整行,扫一眼就知道这一批还差几条。
分母是当前筛选范围的行数,判据与发布防呆同源(entryProgress → judgeEntryRow)。
-->
<div class="entry-progress" :class="{ 'is-done': entryBatchProgress.done }">
<span class="entry-progress-text">{{ entryBatchProgress.text }}</span>
<div
class="mx-progress mx-progress-lg entry-progress-track"
:class="{
'mx-progress-success': entryBatchProgress.done,
'mx-progress-warning': !entryBatchProgress.done && entryBatchProgress.error > 0,
}"
>
<div class="mx-progress-bar" :style="{ width: `${entryBatchProgress.percent}%` }"></div>
</div>
</div>
<!--
延迟结算提示:低调地放在进度条下面,只说明「这一组还没结算、榜单不跟批次走」,
不挡录入。右侧「立即结算」是人工兜底(比如有人退赛但没标 scratched)。
-->
<div v-if="pendingSettlement !== null" class="entry-settlement">
<MxIcon name="warning" :size="14" />
<span class="entry-settlement-text">
延迟结算:本组 {{ pendingSettlement.published }}/{{ pendingSettlement.required }} 人已出成绩,
名次与榜单等该组全部录完后一起更新(本页预览的名次仍是实时的)。
</span>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canWrite || settling"
@click="settleDialog = true"
>
立即结算
</button>
</div>
<div v-if="selectedRows.length > 0" class="heat-bar">
<span class="heat-bar-title"><MxIcon name="entry" :size="15" /> 已选 {{ selectedRows.length }} 行</span>
<label class="heat-field">
@@ -1233,6 +1522,52 @@ watch(
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="clearSelection">取消选择</button>
</div>
<!--
报名项批量操作栏:作用范围就是上面勾选的行(= 当前批次筛选范围内的行)。
退赛可逆、移出不可逆,所以两者都过确认框,先把「动谁、重算几个组别」说清。
dq / dns 是别人的判定,服务端会跳过,这里也把按钮的可用性说白。
-->
<div v-if="selectedRows.length > 0" class="entry-batch-bar">
<span class="entry-batch-title">
<MxIcon name="entry" :size="15" /> 批量操作 {{ selectedRows.length }} 行
</span>
<span class="entry-batch-hint">
仅作用于当前筛选范围({{ scopeWord }});{{ selectedUnrankableCount }} 人已非正常参赛
</span>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!canWrite || batchBusy || selectedUnrankableCount === selectedRows.length"
:title="
selectedUnrankableCount === selectedRows.length
? '选中的行都已是退赛 / 取消资格 / 未出场,没有可退赛的人'
: '标记为退赛(0 分且不占名次),可随时批量恢复'
"
@click="requestBatchAction('scratch')"
>
<MxIcon name="close" :size="14" /> 批量退赛
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canWrite || batchBusy || selectedScratchedCount === 0"
:title="selectedScratchedCount === 0 ? '选中的行里没有已退赛的人' : `把 ${selectedScratchedCount} 人恢复为正常参赛`"
@click="requestBatchAction('restore')"
>
<MxIcon name="check" :size="14" /> 批量恢复<span v-if="selectedScratchedCount > 0">({{ selectedScratchedCount }})</span>
</button>
<span class="heat-bar-split"></span>
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!canWrite || batchBusy"
title="从报名名单里物理删除,连同成绩与积分流水一起删除,不可恢复"
@click="requestBatchAction('remove')"
>
<MxIcon name="warning" :size="14" /> 批量移出报名
</button>
</div>
<div v-if="showAvgMode" class="avg-mode-bar">
<span class="avg-mode-title"><MxIcon name="adjust" :size="15" /> 平均值口径</span>
<label
@@ -1273,7 +1608,7 @@ watch(
type="checkbox"
:checked="allVisibleSelected"
:disabled="visibleRows.length === 0"
title="全选当前可见行"
:title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行)`"
@change="toggleAllVisible"
/>
</th>
@@ -1328,7 +1663,8 @@ watch(
<td>
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
</td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
<td class="result-cell">
<input
v-model="row.resultText"
@@ -1373,7 +1709,13 @@ watch(
/>
<span v-else class="rank-auto mono">{{ rankOf(row) ?? '—' }}</span>
</td>
<td>
<td class="status-cell">
<span
v-if="row.entryStatus !== 'registered'"
class="mx-badge entry-status-badge"
:class="row.entryStatus === 'scratched' ? 'mx-badge-warning' : 'mx-badge-error'"
:title="entryStatusTitle(row.entryStatus)"
>{{ ENTRY_STATUS_LABEL[row.entryStatus] }}</span>
<select
v-model="row.status"
data-cell
@@ -1503,6 +1845,23 @@ watch(
@cancel="unpublishDialog = false"
/>
<ConfirmDialog
:open="settleDialog"
tone="warning"
title="立即结算该组积分?"
:message="`将按本组当前已发布的成绩重算各单位总分并刷新奖牌榜,未录完的人不计入。`"
:details="[
pendingSettlement === null
? '本组已无未结算内容'
: `本组已发布 ${pendingSettlement.published}/${pendingSettlement.required} 人`,
'结算后继续补录并发布,全组录完时还会再自动结算一次',
]"
confirm-text="立即结算"
:pending="settling"
@confirm="confirmSettle"
@cancel="settleDialog = false"
/>
<ConfirmDialog
:open="splitDialog"
tone="warning"
@@ -1532,6 +1891,19 @@ watch(
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<!-- 批量操作确认:移出报名不可恢复,用 danger;退赛 / 恢复可逆,用 warning / info。 -->
<ConfirmDialog
:open="batchDialog"
:tone="batchPlan?.action === 'remove' ? 'danger' : batchPlan?.action === 'restore' ? 'info' : 'warning'"
:title="BATCH_CONFIRM_TITLE[batchPlan?.action ?? 'scratch']"
:message="batchPlan?.message ?? ''"
:details="batchPlan?.details ?? []"
:confirm-text="BATCH_CONFIRM_TEXT[batchPlan?.action ?? 'scratch']"
:pending="batchBusy"
@confirm="confirmBatchAction"
@cancel="cancelBatchAction"
/>
</div>
</template>
@@ -1673,6 +2045,46 @@ watch(
height: 20px;
background: var(--mx-divider);
}
/*
* 报名项批量操作栏:与批次操作条同排出现,勾了行才显示。
* 用警示色边框把「这里有不可恢复动作」和批次操作区分开。
*/
.entry-batch-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
border: 1px solid var(--mx-error);
background: var(--mx-error-container);
}
.entry-batch-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--mx-error);
}
.entry-batch-hint {
font-size: 12px;
color: var(--mx-t3);
}
/* 状态列同时放报名项徽标(已退赛等)与成绩状态下拉,横排不换行。 */
.status-cell {
display: flex;
align-items: center;
gap: 6px;
}
.status-cell .status-select {
flex: 1;
min-width: 0;
}
.entry-status-badge {
flex: none;
white-space: nowrap;
}
/*
* 批次列只放「第 N 批 / 未分批」这类短文本,窄列即可:
* 自动布局下 nowrap + width:1% 会收缩到文字宽度,不会挤坏单位 / 运动员列
@@ -1943,6 +2355,55 @@ watch(
align-items: center;
gap: 6px;
}
/*
* 批次录入进度:横跨整行、文字加粗,是这一屏最显眼的「还差几条」指示;
* 进度条本身用 MIUIx 的 mx-progress / mx-progress-success(录完变绿),不引新样式。
*/
.entry-progress {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border: 1px solid var(--mx-border);
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.entry-progress-text {
flex: none;
font-size: 13px;
font-weight: 600;
color: var(--mx-t2);
font-variant-numeric: tabular-nums;
}
.entry-progress.is-done .entry-progress-text {
color: var(--mx-green);
}
/* 延迟结算提示:中性色 + 细边,属于「知道就好」的信息,不抢进度条与成绩格的注意力。 */
.entry-settlement {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px dashed var(--mx-border);
border-radius: var(--mx-r-sm);
background: transparent;
font-size: 12px;
color: var(--mx-t3);
}
.entry-settlement-text {
flex: 1;
min-width: 0;
line-height: 1.6;
}
.entry-progress-track {
flex: 1;
min-width: 120px;
}
/* 姓名后的号码布:小号、弱化、等宽,补一次号码但不抢姓名的位置。 */
.bib-inline {
font-size: 11px;
color: var(--mx-t3);
}
.hotkey-hint {
display: flex;
align-items: center;