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;
+394 -4
View File
@@ -21,6 +21,13 @@ import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
import {
downloadBaseDataXLSX,
downloadEntriesXLSX,
importBaseDataXLSX,
type BaseDataImportResult,
} from '@/shared/basedata'
import type { EventCategory } from '@/shared'
/**
* 导出与赛事包(devdocs/07 第 5 节)。
@@ -32,10 +39,11 @@ import { useToastStore } from '../stores/toast'
* 因此导入完成后还要在项目列表里切换,页面才看得到导入的数据。
*/
type TabKey = 'results' | 'project' | 'rules'
type TabKey = 'results' | 'basedata' | 'project' | 'rules'
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
{ key: 'results', label: '结果导出' },
{ key: 'basedata', label: '基础数据' },
{ key: 'project', label: '赛事项目包' },
{ key: 'rules', label: '规则包' },
]
@@ -119,6 +127,147 @@ const rulesPreview = ref<RulePreview | null>(null)
const rulesApplyResult = ref<RuleApplyResult | null>(null)
const confirmRulesApply = ref(false)
// ── 报名名单(xlsx,可按小项 / 组别 / 单位 / 批次筛) ────────────────────────
const entryEventId = ref<number | null>(null)
const entryCategoryId = ref<number | null>(null)
const entryTeamId = ref<number | null>(null)
/** 批次用字符串存:空串 = 不筛,交给后端时再转成数字。 */
const entryHeat = ref('')
const entryCategories = ref<EventCategory[]>([])
const entryExporting = ref(false)
/**
* 换小项就把组别重置并重新拉取:组别 id 只在小项内有意义,
* 沿用上一个小项的组别会把筛选条件悄悄变成「没有数据」。
*/
watch(entryEventId, async (id) => {
entryCategoryId.value = null
entryCategories.value = []
if (id === null) return
try {
entryCategories.value = await adminApi.client.events.categories(id)
} catch (caught) {
toast.error('读取组别失败', describeError(caught))
}
})
async function exportEntries(): Promise<void> {
let heat: number | undefined
const rawHeat = entryHeat.value.trim()
if (rawHeat !== '') {
const parsed = Number(rawHeat)
if (!Number.isInteger(parsed) || parsed <= 0) {
toast.warning('批次需要是正整数(留空表示不筛)')
return
}
heat = parsed
}
entryExporting.value = true
try {
const file = await downloadEntriesXLSX({
eventId: entryEventId.value ?? undefined,
categoryId: entryCategoryId.value ?? undefined,
teamId: entryTeamId.value ?? undefined,
heat,
})
adminApi.client.exports.save(file.blob, file.filename)
toast.success(`已导出 ${file.filename}`)
} catch (caught) {
toast.error('导出失败', describeError(caught))
} finally {
entryExporting.value = false
}
}
// ── 基础数据包(单位 / 大项 / 小项 / 组别) ──────────────────────────────────
const baseDataFile = ref<File | null>(null)
const baseDataInput = ref<HTMLInputElement | null>(null)
const baseDataExporting = ref(false)
const baseDataPreviewing = ref(false)
const baseDataApplying = ref(false)
const baseDataError = ref<string | null>(null)
const baseDataResult = ref<BaseDataImportResult | null>(null)
const confirmBaseDataApply = ref(false)
async function exportBaseData(): Promise<void> {
baseDataExporting.value = true
baseDataError.value = null
try {
const file = await downloadBaseDataXLSX()
adminApi.client.exports.save(file.blob, file.filename)
toast.success('基础数据包已导出', '含 单位 / 大项 / 小项 / 组别 四张工作表,可原样导回')
} catch (caught) {
baseDataError.value = describeError(caught)
} finally {
baseDataExporting.value = false
}
}
function pickBaseDataFile(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
baseDataResult.value = null
baseDataError.value = null
if (file === null) {
baseDataFile.value = null
return
}
if (!/\.xlsx$/i.test(file.name)) {
baseDataError.value = '基础数据包必须是 .xlsx 文件:单位 / 大项 / 小项 / 组别分在四张工作表里'
baseDataFile.value = null
input.value = ''
return
}
baseDataFile.value = file
}
/** 第一步:只算差异不写库。用户看清了再点确认写入。 */
async function previewBaseData(): Promise<void> {
const file = baseDataFile.value
if (file === null) {
baseDataError.value = '请先选择 .xlsx 基础数据包'
return
}
baseDataPreviewing.value = true
baseDataError.value = null
baseDataResult.value = null
try {
baseDataResult.value = await importBaseDataXLSX(file, { dryRun: true })
} catch (caught) {
baseDataError.value = describeError(caught)
} finally {
baseDataPreviewing.value = false
}
}
function requestBaseDataApply(): void {
if (baseDataResult.value === null) return
confirmBaseDataApply.value = true
}
async function applyBaseData(): Promise<void> {
const file = baseDataFile.value
if (file === null) return
confirmBaseDataApply.value = false
baseDataApplying.value = true
baseDataError.value = null
try {
const result = await importBaseDataXLSX(file, { dryRun: false })
baseDataResult.value = result
toast.success(
'基础数据已写入',
`新增 ${result.added} 条 / 更新 ${result.updated} 条` +
(result.errorCount > 0 ? ` / ${result.errorCount} 行出错未写入` : ''),
)
// 导入会新增单位 / 大项 / 小项,页面上的下拉与基础数据页都要跟着刷新。
void data.load()
} catch (caught) {
baseDataError.value = describeError(caught)
} finally {
baseDataApplying.value = false
}
}
const isAdmin = computed(() => auth.user?.role === 'admin')
const activeProjectName = computed(() => projects.value.find((item) => item.active)?.name ?? null)
@@ -484,8 +633,203 @@ async function applyRules(): Promise<void> {
</div>
</section>
<!-- 报名名单:同样是 xlsx 导出,但读的是报名数据(批次、号码布、状态都在这一侧) -->
<section v-if="activeTab === 'results'" class="mx-card entries-card">
<div class="mx-card-head">
<span class="mx-card-title">报名名单(xlsx)</span>
</div>
<div class="mx-card-body">
<p class="hint">
按小项 / 组别 / 单位 / 批次筛选,留空表示该维度不筛。表里含小项编码与名称、组别、单位、姓名、证件号、号码布、<strong>批次</strong>、性别、年龄、状态与备注;号码布按文本导出,前导零不会丢。
</p>
<div class="form-grid">
<div class="field">
<label class="mx-label" for="entries-event">小项</label>
<div class="mx-input-wrap">
<select id="entries-event" v-model="entryEventId" class="mx-input">
<option :value="null">全部小项</option>
<option v-for="item in eventOptions" :key="item.id" :value="item.id">{{ item.label }}</option>
</select>
</div>
</div>
<div class="field">
<label class="mx-label" for="entries-category">组别</label>
<div class="mx-input-wrap">
<select
id="entries-category"
v-model="entryCategoryId"
class="mx-input"
:disabled="entryEventId === null || entryCategories.length === 0"
>
<option :value="null">全部组别</option>
<option v-for="item in entryCategories" :key="item.id" :value="item.id">{{ item.name }}</option>
</select>
</div>
</div>
<div class="field">
<label class="mx-label" for="entries-team">单位</label>
<div class="mx-input-wrap">
<select id="entries-team" v-model="entryTeamId" class="mx-input">
<option :value="null">全部单位</option>
<option v-for="team in data.teams" :key="team.id" :value="team.id">
{{ team.name }}({{ team.code }})
</option>
</select>
</div>
</div>
<div class="field">
<label class="mx-label" for="entries-heat">批次</label>
<div class="mx-input-wrap">
<input
id="entries-heat"
v-model="entryHeat"
class="mx-input"
inputmode="numeric"
placeholder="全部批次(留空)"
/>
</div>
</div>
</div>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="entryExporting"
@click="exportEntries"
>
<MxIcon name="export" :size="15" />
{{ entryExporting ? '导出中…' : '导出报名名单' }}
</button>
</div>
</section>
<!-- 基础数据包:导出可原样导回;导入按编码 upsert,先预览差异再写入 -->
<div v-if="activeTab === 'basedata'" class="basedata-grid">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">导出基础数据包</span>
</div>
<div class="mx-card-body">
<p class="hint">
一个工作簿含四张工作表:单位 / 大项 / 小项 / 组别(组别带所属小项编码)。列名是中文表头,导出的文件可以直接原样导回来,识别到的差异为 0。
</p>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="baseDataExporting"
@click="exportBaseData"
>
<MxIcon name="export" :size="15" />
{{ baseDataExporting ? '导出中…' : '导出 xlsx' }}
</button>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">导入基础数据包</span></div>
<div class="mx-card-body">
<p v-if="!isAdmin" class="notice">导入基础数据会批量写入单位 / 大项 / 小项 / 组别,只有管理员可操作。</p>
<div class="file-row">
<input
ref="baseDataInput"
class="file-input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@change="pickBaseDataFile"
/>
<button class="mx-btn mx-btn-secondary" type="button" @click="baseDataInput?.click()">
<MxIcon name="database" :size="15" /> 选择 .xlsx
</button>
<span class="file-name mono">{{ baseDataFile?.name ?? '未选择文件' }}</span>
</div>
<p class="hint">
按编码 upsert:单位 / 大项按编码,小项按编码,组别按「小项编码 + 组别名」;存在就更新、不存在就新建。
文件里没写的数据一条都不会删。
</p>
<p class="hint">导入分两步:先「预览差异」,确认无误后再写入。</p>
<p v-if="baseDataError !== null" class="notice error">{{ baseDataError }}</p>
<button
class="mx-btn mx-btn-secondary full"
type="button"
:disabled="!isAdmin || baseDataPreviewing || baseDataFile === null"
@click="previewBaseData"
>
{{ baseDataPreviewing ? '比对中…' : '预览差异' }}
</button>
<div v-if="baseDataResult !== null" class="preview-box">
<div class="preview-head">
<span class="mx-badge mx-badge-neutral">新增 {{ baseDataResult.added }} 条</span>
<span class="mx-badge mx-badge-warning">更新 {{ baseDataResult.updated }} 条</span>
<span class="mx-badge" :class="baseDataResult.errorCount > 0 ? 'mx-badge-error' : 'mx-badge-neutral'">
错误 {{ baseDataResult.errorCount }} 条
</span>
<span class="preview-meta">
{{ baseDataResult.dryRun ? '预览(未写库)' : '已写入' }}
<template v-if="baseDataResult.unchanged > 0">· 无变化 {{ baseDataResult.unchanged }} 条</template>
</span>
</div>
<div v-if="baseDataResult.errors.length > 0" class="error-list">
<div v-for="(message, index) in baseDataResult.errors" :key="index" class="error-line">
{{ message }}
</div>
</div>
<div v-if="baseDataResult.changes.length === 0" class="mx-empty">
<div class="mx-empty-title">没有差异</div>
<div class="mx-empty-desc">文件内容与库里一致,无需写入</div>
</div>
<div v-else class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>工作表</th>
<th>行</th>
<th>变化</th>
<th>对象</th>
<th>字段差异</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in baseDataResult.changes" :key="`${row.sheet}-${row.line}-${index}`">
<td>{{ row.sheet }}</td>
<td class="mono">第 {{ row.line }} 行</td>
<td>
<span class="mx-badge" :class="row.action === 'added' ? 'mx-badge-success' : 'mx-badge-warning'">
{{ row.action === 'added' ? '新增' : '更新' }}
</span>
</td>
<td class="mono">{{ row.key }}</td>
<td class="detail">
<span v-for="f in row.fields" :key="f.field" class="field-diff">
{{ f.field }}:{{ f.from === '' ? '(空)' : f.from }} → {{ f.to === '' ? '(空)' : f.to }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<button
v-if="baseDataResult.dryRun"
class="mx-btn mx-btn-danger full"
type="button"
:disabled="!isAdmin || baseDataApplying"
@click="requestBaseDataApply"
>
{{ baseDataApplying ? '写入中…' : '确认写入' }}
</button>
</div>
</div>
</section>
</div>
<!-- 赛事项目包 -->
<div v-else-if="activeTab === 'project'" class="project-grid">
<div v-if="activeTab === 'project'" class="project-grid">
<section class="mx-card wide">
<div class="mx-card-head">
<span class="mx-card-title">赛事项目列表</span>
@@ -676,7 +1020,7 @@ async function applyRules(): Promise<void> {
</div>
<!-- 规则包 -->
<div v-else class="rules-grid">
<div v-if="activeTab === 'rules'" class="rules-grid">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">导出规则包</span>
@@ -815,6 +1159,24 @@ async function applyRules(): Promise<void> {
@cancel="confirmRulesApply = false"
/>
<ConfirmDialog
:open="confirmBaseDataApply"
tone="danger"
title="写入基础数据?"
:message="`将按文件内容新增 ${baseDataResult?.added ?? 0} 条、更新 ${baseDataResult?.updated ?? 0} 条单位 / 大项 / 小项 / 组别。`"
:details="[
'按编码 upsert:存在就更新、不存在就新建',
'文件里没写的既有数据不会被删除',
baseDataResult !== null && baseDataResult.errorCount > 0
? `${baseDataResult.errorCount} 行有错误,这些行会被跳过,其余行照常写入`
: '本次预览没有行级错误',
]"
confirm-text="确认写入"
:pending="baseDataApplying"
@confirm="applyBaseData"
@cancel="confirmBaseDataApply = false"
/>
<p class="footer-note">当前数据更新于 {{ formatDateTime(data.lastRefreshAt) }}</p>
</div>
</template>
@@ -879,6 +1241,33 @@ async function applyRules(): Promise<void> {
gap: 16px;
align-items: start;
}
/* 报名名单排在结果导出卡片下方,同样占满整行。 */
.entries-card {
margin-top: 16px;
}
/* 基础数据包:导出与导入各一张卡,宽度与赛事包一致。 */
.basedata-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 16px;
align-items: start;
}
.error-list {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: rgba(248, 113, 113, 0.1);
}
.error-line {
font-size: 12px;
color: var(--mx-error);
line-height: 1.6;
}
.field-diff {
display: block;
}
/* 项目列表与导出 / 导入卡片不同,是整行的总览表。 */
.project-grid > .wide {
grid-column: 1 / -1;
@@ -993,7 +1382,8 @@ async function applyRules(): Promise<void> {
}
@media (max-width: 1279px) {
.project-grid,
.rules-grid {
.rules-grid,
.basedata-grid {
grid-template-columns: 1fr;
}
}
+26 -8
View File
@@ -13,6 +13,7 @@ import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
import { liveOnPublishOf } from '../components/system/systemApi'
import { judgeEntryRow, type EntryRowJudgement } from './entryGuards'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
@@ -72,6 +73,11 @@ const returnTarget = ref<DraftGroup | null>(null)
const returnedKeys = ref<string[]>([])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
/**
* 延迟结算:发布只落成绩,榜单等该组全部录完才更新。
* 这里用的是快照里的设置;读不到(非 admin 且快照未到)时按默认「实时结算」处理。
*/
const deferredSettlement = computed(() => !liveOnPublishOf(data.settings))
/** 非 admin 会话的快照会把折算系数清零(dto.WithoutAdminFields),0 兜底成 1,否则个人项目折算分全变 0。 */
const weight = computed(() => {
const w = data.settings?.standings.individualToTeamWeight ?? 1
@@ -352,6 +358,20 @@ function requestPublish(group: DraftGroup): void {
publishTarget.value = group
}
/** 发布确认框正文:延迟结算下不能说「发布即重算积分榜」。 */
const publishMessage = computed(() => {
const group = publishTarget.value
if (group === null) return ''
const scope =
skippedCount(group) > 0
? `本组还有 ${skippedCount(group)} 条没有成绩,本次将发布已填的 ${publishableCount(group)} 条;其余保持未发布,补录后再发布即可。`
: `本次将发布该组全部 ${publishableCount(group)} 条。`
const tail = deferredSettlement.value
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后才更新。'
: '发布后立即上屏并重算积分榜。'
return `${scope}本次影响 ${group.affectedTeams} 个单位,最大分差 ${formatPoints(group.maxDelta)} 分。${tail}`
})
async function confirmPublish(): Promise<void> {
const group = publishTarget.value
if (group === null) return
@@ -361,7 +381,11 @@ async function confirmPublish(): Promise<void> {
publishTarget.value = null
await load()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
if (deferredSettlement.value) {
toast.success('已发布本批成绩', `延迟结算:${group.categoryName} 全组录完后才更新名次与榜单`)
} else {
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
}
} catch (caught) {
toast.error('发布失败', caught instanceof Error ? caught.message : null)
} finally {
@@ -633,13 +657,7 @@ watch(
:open="publishTarget !== null"
tone="warning"
title="确认发布该组成绩?"
:message="
publishTarget === null
? ''
: skippedCount(publishTarget) > 0
? `本组还有 ${skippedCount(publishTarget)} 条没有成绩,本次将发布已填的 ${publishableCount(publishTarget)} 条;其余保持未发布,补录后再发布即可。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
: `本次将发布该组全部 ${publishableCount(publishTarget)} 条。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
"
:message="publishMessage"
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
confirm-text="确认发布"
:pending="publishing"
+7 -3
View File
@@ -4,20 +4,23 @@ import PageHeader from '../components/PageHeader.vue'
import UsersPanel from '../components/system/UsersPanel.vue'
import BackupPanel from '../components/system/BackupPanel.vue'
import EventInfoPanel from '../components/system/EventInfoPanel.vue'
import SessionPanel from '../components/system/SessionPanel.vue'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
/**
* 系统页(devdocs/07 第 1 节)。
*
* 三块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间,
* 四块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间,
* 备份和赛事信息又很少同时改。每个面板各自负责加载态 / 空态 / 错误态。
*/
type TabKey = 'users' | 'backup' | 'event'
type TabKey = 'users' | 'session' | 'backup' | 'event'
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
{ key: 'users', label: '用户管理' },
// 登录有效期是机器级配置(system.db),与赛事信息那种随赛事包走的配置分开摆。
{ key: 'session', label: '登录有效期' },
{ key: 'backup', label: '备份' },
{ key: 'event', label: '赛事信息' },
]
@@ -42,7 +45,7 @@ watch(
<template>
<div>
<PageHeader title="系统" subtitle="用户管理 / 备份恢复 / 赛事信息(devdocs/08 第 2、6 节)" />
<PageHeader title="系统" subtitle="用户管理 / 登录有效期 / 备份恢复 / 赛事信息(devdocs/08 第 2、6 节)" />
<div class="system-toolbar">
<div class="mx-tabs">
@@ -70,6 +73,7 @@ watch(
<p v-if="data.error !== null" class="role-note error">数据加载失败:{{ data.error }}</p>
<UsersPanel v-if="isAdmin && activeTab === 'users'" />
<SessionPanel v-else-if="isAdmin && activeTab === 'session'" />
<BackupPanel v-else-if="isAdmin && activeTab === 'backup'" />
<EventInfoPanel v-else />
</div>
+276
View File
@@ -0,0 +1,276 @@
import { ApiError } from '@/shared'
import type { ApiErrorCode, EntryStatus } from '@/shared'
/**
* 报名项批量操作(退赛 / 恢复 / 移出报名)的前端逻辑。
*
* 网络层直接 fetch 而不是走 adminApi:client.ts 的接口表与 shared/api.ts 归口在共享层,
* 本功能只服务录入页,先把请求构造与确认文案收在这一个模块里,便于单测;
* CSRF 与 X-Request-Id 的补法与 shared/api.ts 的写请求保持一致,否则写操作会被 403 挡回。
*/
/** 与 server/internal/httpapi/entries_batch.go 的 maxEntryBatch 一致。 */
export const MAX_ENTRY_BATCH = 500
export const ENTRY_BATCH_PATH = '/api/entries/batch'
/** 确认框里逐条列出「将被删的人」时最多列几行,超出的只报数。 */
const MAX_LISTED_PEOPLE = 20
export type EntryBatchAction = 'scratch' | 'restore' | 'remove'
export interface EntryBatchPayload {
action: EntryBatchAction
ids: number[]
reason?: string
requestId?: string
}
export interface EntryBatchItem {
id: number
ok: boolean
error?: string
skipped?: boolean
note?: string
}
export interface EntryBatchSummary {
ok: number
failed: number
skipped: number
changed: number
removedEntries: number
removedResults: number
removedLedger: number
removedBreaks: number
recomputedEvents: number[]
}
export interface EntryBatchResult {
items: EntryBatchItem[]
summary: EntryBatchSummary
}
/** 批量操作作用于的勾选行;字段只取确认文案与跳过判定需要的部分。 */
export interface EntryBatchRow {
entryId: number
/** 展示名:运动员姓名 → 队员首人 → 单位(与表格行文案同口径)。 */
name: string
bib: string | null
team: string
categoryId: number
entryStatus: EntryStatus
}
export interface EntryBatchPlan {
action: EntryBatchAction
/** 去重后的报名项 id(服务端同样会去重,这里先去掉重复勾选)。 */
ids: number[]
/** 真正会被改动的行数。 */
changed: number
/** 会被跳过的行及其原因(dq / dns 是别人的判定,不覆盖)。 */
skipped: { name: string; note: string }[]
/** 会重算的组别数量:重算按 (小项, 组别) 走,本页只有当前小项。 */
scopeCount: number
message: string
details: string[]
}
/**
* 去重并丢掉非法 id(0 / 负数 / 非整数),保持勾选顺序。
* 与服务端 dedupeIDs 同一口径:重复勾选不该变成两条请求明细。
*/
export function dedupeEntryIds(ids: number[]): number[] {
const out: number[] = []
const seen = new Set<number>()
for (const id of ids) {
if (!Number.isInteger(id) || id <= 0 || seen.has(id)) continue
seen.add(id)
out.push(id)
}
return out
}
/** 批量条数校验;通过返回 null,否则返回可直接上屏的中文提示。 */
export function entryBatchSizeError(count: number): string | null {
if (count === 0) return '请先勾选要操作的报名项'
if (count > MAX_ENTRY_BATCH) {
return `一次最多处理 ${MAX_ENTRY_BATCH} 条报名项,请分批提交`
}
return null
}
/** 该行在当前动作下会不会被改动;不改动时返回跳过原因(与服务端文案一致)。 */
export function entryBatchSkipNote(action: EntryBatchAction, status: EntryStatus): string | null {
if (action === 'remove') return null
if (action === 'scratch' && status === 'registered') return null
if (action === 'restore' && status === 'scratched') return null
switch (status) {
case 'scratched':
return '已是退赛状态,跳过'
case 'registered':
return '当前正常参赛,无需恢复'
case 'dq':
return '已被判罚取消资格(dq),跳过,不覆盖原判定'
case 'dns':
return '已标记未出场(dns),跳过,不覆盖原判定'
}
return `当前状态为「${status}」,跳过`
}
/** 行在确认框里的身份行:姓名|号码布|单位。 */
function personLine(row: EntryBatchRow): string {
return `${row.name || '(未署名)'}|号码 ${row.bib ?? '—'}|${row.team || '未知单位'}`
}
/**
* 把一次批量操作整理成确认框能直接用的内容:
* 前端先算清「动谁、跳过谁、重算几个组别」,不让操作员点完才知道。
*/
export function planEntryBatch(action: EntryBatchAction, rows: EntryBatchRow[]): EntryBatchPlan {
const ids = dedupeEntryIds(rows.map((row) => row.entryId))
// 重复勾选不该在「动几个人」里数两遍:按 entryId 去重后再统计。
const seen = new Set<number>()
const unique = rows.filter((row) => {
if (seen.has(row.entryId)) return false
seen.add(row.entryId)
return true
})
const scopes = new Set(unique.map((row) => row.categoryId))
const changedRows: EntryBatchRow[] = []
const skipped: { name: string; note: string }[] = []
for (const row of unique) {
const note = entryBatchSkipNote(action, row.entryStatus)
if (note === null) changedRows.push(row)
else skipped.push({ name: row.name || '(未署名)', note })
}
const changed = changedRows.length
const scopeCount = scopes.size
const details: string[] = []
if (action === 'remove') {
details.push(`将删除以下 ${changed} 人(物理删除,不可恢复):`)
for (const row of changedRows.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${personLine(row)}`)
if (changedRows.length > MAX_LISTED_PEOPLE) {
details.push(`…还有 ${changedRows.length - MAX_LISTED_PEOPLE} 人未列出`)
}
} else if (skipped.length > 0) {
details.push(`以下 ${skipped.length} 人会被跳过(不覆盖已有判定):`)
for (const item of skipped.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${item.name}:${item.note}`)
if (skipped.length > MAX_LISTED_PEOPLE) {
details.push(`…还有 ${skipped.length - MAX_LISTED_PEOPLE} 人未列出`)
}
}
let message = ''
if (action === 'scratch') {
message = `将把选中的 ${changed} 人标记为退赛(0 分且不占名次),随后按新名次重算积分榜。退赛随时可以用「批量恢复」撤回。`
} else if (action === 'restore') {
message = `将把选中的 ${changed} 人恢复为正常参赛,随后按新名次重算积分榜。`
} else {
message = `将从报名名单中物理删除选中的 ${changed} 人,成绩与积分流水一并删除,不可恢复。`
}
// 本页只操作当前小项;重算按 (小项, 组别) 走,所以组别数就是这里的规模。
if (scopeCount > 0) {
details.push(`会重算 1 个小项(当前项目)、${scopeCount} 个组别的名次与积分。`)
}
if (action === 'remove' && changed > MAX_LISTED_PEOPLE) {
message += `这会重算 1 个小项(当前项目)、${scopeCount} 个组别,别人的名次会变。`
}
return { action, ids, changed, skipped, scopeCount, message, details }
}
/** 批量操作成功后的 toast 副标题:逐条成败 + 删除规模 + 重算范围。 */
export function entryBatchOutcome(result: EntryBatchResult): string {
const { summary } = result
const parts: string[] = []
parts.push(`成功 ${summary.ok} 条${summary.skipped > 0 ? `(跳过 ${summary.skipped} 条)` : ''}`)
if (summary.failed > 0) parts.push(`失败 ${summary.failed} 条`)
if (summary.removedEntries > 0) parts.push(`删除报名 ${summary.removedEntries} 条`)
if (summary.removedResults > 0) parts.push(`删除成绩 ${summary.removedResults} 条`)
if (summary.removedLedger > 0) parts.push(`删除流水 ${summary.removedLedger} 条`)
if (summary.recomputedEvents.length > 0) parts.push(`重算 ${summary.recomputedEvents.length} 个小项`)
return parts.join(';')
}
function readCsrfToken(): string | null {
if (typeof document === 'undefined') return null
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
return match?.[1] === undefined ? null : decodeURIComponent(match[1])
}
function defaultRequestId(): string {
const cryptoApi: Crypto | undefined = globalThis.crypto
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID()
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const KNOWN_CODES: readonly string[] = [
'AUTH_REQUIRED',
'AUTH_FAILED',
'FORBIDDEN',
'VALIDATION_FAILED',
'CONFLICT',
'STATE_INVALID',
'RATE_LIMITED',
'INTERNAL',
'NETWORK',
'TIMEOUT',
'PARSE_FAILED',
]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError {
const safe: ApiErrorCode = typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL'
return new ApiError(safe, message, status, detail)
}
/**
* 提交批量操作。写操作不降级到示例数据:示例数据模式下的「成功」会让操作员
* 以为已经退赛 / 删干净了,页面必须先看 data.isMock 再决定是否调用这里。
*/
export async function submitEntryBatch(payload: EntryBatchPayload): Promise<EntryBatchResult> {
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'X-Request-Id': defaultRequestId(),
}
const token = readCsrfToken()
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
let response: Response
try {
response = await fetch(ENTRY_BATCH_PATH, {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({ ...payload, requestId: payload.requestId ?? defaultRequestId() }),
})
} catch (caught) {
throw apiError('NETWORK', '无法连接服务端,批量操作没有生效', 0, caught)
}
let body: unknown
try {
body = await response.json()
} catch (caught) {
throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught)
}
if (!isRecord(body) || typeof body['ok'] !== 'boolean') {
throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body)
}
if (body['ok'] !== true) {
const error = isRecord(body['error']) ? body['error'] : {}
const message = typeof error['message'] === 'string' ? error['message'] : `批量操作失败(HTTP ${response.status})`
throw apiError(error['code'], message, response.status, error['detail'])
}
if (!response.ok) {
throw apiError('INTERNAL', `批量操作失败(HTTP ${response.status})`, response.status, body)
}
return body['data'] as EntryBatchResult
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 批次录入进度(纯函数):当前筛选范围内的「已录 / 总数」、可视化百分比与完成态。
*
* 抽出来的原因有两条:
* 一是「已录」的判据必须与发布防呆、行徽标完全同源,所以这里直接调用
* entryGuards 的 judgeEntryRow,绝不另写一套成绩解析——否则会出现
* 「进度条说录完了、点发布却说缺 8 条」这种自相矛盾的提示;
* 二是分母必须是**当前筛选范围**的行数(选中某一批时只数这一批),
* 这条容易写错也最难在界面上核对,需要能被 node --test 直接断言。
*/
import { heatLabel, filterRowsByHeat, type HeatFilter } from './entryHeat'
import { judgeEntryRow } from './entryGuards'
/** 只要求进度需要的字段,便于表格行与测试夹具共用。 */
export interface ProgressRow {
/** 批次号(null = 未分批);只影响分组,不参与算分。 */
heat: number | null
resultText: string
rank: number | null
/** 成绩状态(ok / dns / dq / dnf / withdrawn)。 */
status: string
}
export interface EntryProgressInput {
/** 组别下的全部行(未做批次筛选)。 */
rows: readonly ProgressRow[]
filter: HeatFilter
unit: string
/** 该项目是否以手工名次为成绩(manual / compare=none),与发布口径一致。 */
needsRank: boolean
}
export interface EntryProgress {
total: number
filled: number
missing: number
/** 写错的行数:既不计入「已录」,也不阻塞进度条显示,但要在文案里点出来。 */
error: number
/** 是否全部录完(有错的行不算已录,所以有错时永远不是完成态)。 */
done: boolean
/** 进度条宽度百分比(0-100,四舍五入)。 */
percent: number
/** 作用域文案:全部 / 第 N 批 / 未分批。 */
scope: string
/** 可直接展示的完整文案,如「第 2 批 已录 5/8 · 3 条有错」。 */
text: string
}
/** 进度的作用域称谓,与批次下拉的选项文案同源。 */
export function progressScopeLabel(filter: HeatFilter): string {
return filter === 'all' ? '全部' : heatLabel(filter === 'none' ? null : filter)
}
/**
* 统计当前筛选范围内的录入进度。
*
* 分母 = filterRowsByHeat 后的行数;分子只算 judgeEntryRow 判为 filled 的行,
* 未录(missing)与有错(error)都不算,有错时额外在文案里报数。
*/
export function entryProgress(input: EntryProgressInput): EntryProgress {
const rows = filterRowsByHeat(input.rows, input.filter)
let filled = 0
let missing = 0
let error = 0
for (const [index, row] of rows.entries()) {
// 行标签只进 blocker / warning 文案;这里只取 state,给行号即可定位。
const judged = judgeEntryRow({
label: `第 ${index + 1} 行`,
resultText: row.resultText,
unit: input.unit,
status: row.status,
rank: row.rank,
needsRank: input.needsRank,
})
if (judged.state === 'filled') filled += 1
else if (judged.state === 'error') error += 1
else missing += 1
}
const total = rows.length
const done = total > 0 && filled === total
const scope = progressScopeLabel(input.filter)
const head = done ? `${scope} 已录完 ${filled}/${total}` : `${scope} 已录 ${filled}/${total}`
return {
total,
filled,
missing,
error,
done,
percent: total === 0 ? 0 : Math.round((filled / total) * 100),
scope,
// 有错的行会被发布防呆拦下,进度里必须显形,否则「已录 5/8」看不出差在哪。
text: error > 0 ? `${head} · ${error} 条有错` : head,
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* 录入表格「运动员 / 队」单元格的展示片段(纯函数)。
*
* 抽出来的原因:号码布原本只在左边单独一列,录成绩时眼睛要在姓名与号码之间来回跳,
* 所以姓名紧后面要再显示一次号码布;而「姓名怎么回落」与「号码布为空时不占位」
* 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。
*/
export interface EntryNameLike {
athleteName: string
/** 团体 / 接力行的队员名单;个人行为 null。 */
memberNames: string[] | null
}
/** 姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时给占位符。 */
export function athleteCellName(row: EntryNameLike): string {
return row.athleteName || row.memberNames?.join('、') || '—'
}
/**
* 紧跟姓名后的号码布文本;没有号码布(null / 空串 / 只有空白)时返回空串。
*
* 返回空串让模板用 v-if 直接不渲染这个 span:号码布为空的报名项很多,
* 渲染一个空节点会留出一段空白把表格列推歪。号码布按原样去掉首尾空白展示。
*/
export function inlineBibLabel(bib: string | null | undefined): string {
return bib === null || bib === undefined ? '' : bib.trim()
}