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

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Athlete, AthleteInput, Gender } from '@/shared'
import type { AthletePurgeResult } from '@/shared/api'
import type { AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { baseDataApi } from './baseDataApi'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
@@ -47,6 +48,205 @@ const purgePending = ref(false)
/** 删除失败时的说明留在页面上:服务端那句原文(人员已不在等)比一行「操作失败」有用得多。 */
const purgeError = ref<string | null>(null)
// ── 多选与批量操作 ──────────────────────────────────────────────────────────
/**
* 勾选的运动员 id。
*
* 每行一个勾选框,表头是「全选当前筛选结果」——先按单位 / 状态 / 关键字把要处理的人筛出来,
* 再一键全选,是现场最高频的用法;如果表头连被筛掉的人一起选,批量彻底删除就可能删掉
* 看不见的人,所以勾选范围严格等于当前可见的筛选结果。
*/
const selectedIds = ref<Set<number>>(new Set())
/** 批量操作提交中的标志(预览与真删共用,避免重复点击)。 */
const batchPending = ref(false)
/** 批量操作的逐条结果常驻页面:谁没被处理、为什么,不能只靠一闪而过的 toast。 */
const batchMessage = ref<string | null>(null)
/** 发起批量操作时锁定的 id 列表:确认框开着的时候选择不该再变化,提交用的就是这一份。 */
const batchTargetIds = ref<number[]>([])
const batchDisableOpen = ref(false)
const batchPurgeOpen = ref(false)
/** 彻底删除的预览(dryRun 响应):提交前把「会删掉什么、重算哪些小项」摆给操作员看。 */
const bulkPurgePreview = ref<AthleteBatchResult | null>(null)
const filteredIds = computed(() => filtered.value.map((athlete) => athlete.id))
const selectedCount = computed(() => selectedIds.value.size)
const selectedAthletes = computed(() => data.athletes.value.filter((athlete) => selectedIds.value.has(athlete.id)))
const allFilteredSelected = computed(
() => filteredIds.value.length > 0 && filteredIds.value.every((id) => selectedIds.value.has(id)),
)
const someFilteredSelected = computed(
() => !allFilteredSelected.value && filteredIds.value.some((id) => selectedIds.value.has(id)),
)
// 被筛掉 / 被删掉的 id 自动退出选择:批量彻底删除的范围必须始终看得见。
watch(filteredIds, (ids) => {
if (selectedIds.value.size === 0) return
const visible = new Set(ids)
const kept = [...selectedIds.value].filter((id) => visible.has(id))
if (kept.length !== selectedIds.value.size) selectedIds.value = new Set(kept)
})
function toggleOne(id: number, checked: boolean): void {
const next = new Set(selectedIds.value)
if (checked) next.add(id)
else next.delete(id)
selectedIds.value = next
}
function onRowCheck(id: number, event: Event): void {
toggleOne(id, (event.target as HTMLInputElement).checked)
}
function toggleAllFiltered(checked: boolean): void {
const next = new Set(selectedIds.value)
for (const id of filteredIds.value) {
if (checked) next.add(id)
else next.delete(id)
}
selectedIds.value = next
}
function onHeaderCheck(event: Event): void {
toggleAllFiltered((event.target as HTMLInputElement).checked)
}
function clearSelection(): void {
selectedIds.value = new Set()
}
/** 提交前的名单快照:批量删除之后这些人可能已经不在列表里,名字要提前留住。 */
function selectedLabels(): Map<number, string> {
return new Map(selectedAthletes.value.map((athlete) => [athlete.id, athlete.name]))
}
/**
* 汇总一次批量动作的逐条结果。
*
* 有失败就常驻页面并逐条点名(谁、为什么),不能只报「部分成功」——
* 现场要拿着这句话去核对到底谁没被处理。
*/
function reportBatch(verb: string, result: AthleteBatchResult, labels: Map<number, string>): void {
const failures = result.items.filter((item) => !item.ok)
const done = `已${verb} ${result.succeeded} 人`
if (failures.length === 0) {
batchMessage.value = null
toast.success(`${done}(${result.requested} 人中全部成功)`)
return
}
const detail = failures
.map((item) => `${labels.get(item.id) ?? `#${item.id}`}(${item.message ?? '未能处理'})`)
.join('、')
batchMessage.value = `${done},${failures.length} 人未能处理:${detail}`
toast.error(`批量${verb}:${failures.length} 人未处理`, batchMessage.value)
}
function askBatchDisable(): void {
if (selectedCount.value === 0) return
batchTargetIds.value = [...selectedIds.value]
batchMessage.value = null
batchDisableOpen.value = true
}
async function confirmBatchDisable(): Promise<void> {
if (batchPending.value) return
const labels = selectedLabels()
batchPending.value = true
try {
const result = await baseDataApi.athletes.batch('disable', batchTargetIds.value)
reportBatch('停用', result, labels)
batchDisableOpen.value = false
clearSelection()
await data.load(true)
} catch (caught) {
toast.error('批量停用失败', caught instanceof Error ? caught.message : null)
} finally {
batchPending.value = false
}
}
/** 先预览再确认:彻底删除不可逆,必须让操作员看到会删掉谁、重算哪些小项。 */
async function askBatchPurge(): Promise<void> {
if (selectedCount.value === 0) return
batchTargetIds.value = [...selectedIds.value]
batchMessage.value = null
bulkPurgePreview.value = null
batchPending.value = true
try {
bulkPurgePreview.value = await baseDataApi.athletes.batch('purge', batchTargetIds.value, true)
batchPurgeOpen.value = true
} catch (caught) {
toast.error('无法预估删除影响', caught instanceof Error ? caught.message : null)
} finally {
batchPending.value = false
}
}
/** 确认框里逐行摆出的影响:名单、删除规模、要重算的小项,以及不可逆的提醒。 */
const bulkPurgeDetails = computed<string[]>(() => {
const preview = bulkPurgePreview.value
const people = data.athletes.value.filter((athlete) => batchTargetIds.value.includes(athlete.id))
const shown = people
.slice(0, 20)
.map((athlete) => `${athlete.name}(${data.teamById(athlete.teamId)?.name ?? '未知单位'})`)
const lines: string[] = [
`将删除 ${people.length} 人:${shown.join('、')}${people.length > shown.length ? ` 等 ${people.length} 人` : ''}`,
]
if (preview?.purge !== undefined) {
const p = preview.purge
const parts = [`报名 ${p.deletedEntries} 条`, `成绩 ${p.deletedResults} 条`]
if (p.deletedLedger > 0) parts.push(`积分流水 ${p.deletedLedger} 条`)
if (p.deletedBreaks > 0) parts.push(`纪录 ${p.deletedBreaks} 条`)
lines.push(`合计删除 ${parts.join('、')}`)
const events = p.recomputedEvents.map((id) => data.eventById(id)?.name ?? `#${id}`)
lines.push(
events.length > 0
? `会重算 ${events.length} 个小项的排名与积分:${events.join('、')}`
: '没有已发布的成绩,不需要重算名次',
)
}
const missing = (preview?.items ?? []).filter((item) => !item.ok)
if (missing.length > 0) {
lines.push(`其中 ${missing.length} 人已不在名单里,提交时会跳过并单独回报`)
}
if (people.length > 20) {
lines.push(`本次涉及 ${people.length} 人,人数较多时重算更久,请确认名单无误`)
}
lines.push('删除不可恢复;只是想暂时不让他报名,请改用「批量停用」')
return lines
})
async function confirmBatchPurge(): Promise<void> {
if (batchPending.value) return
const labels = new Map(
data.athletes.value.filter((athlete) => batchTargetIds.value.includes(athlete.id)).map((a) => [a.id, a.name]),
)
batchPending.value = true
try {
const result = await baseDataApi.athletes.batch('purge', batchTargetIds.value)
reportBatch('彻底删除', result, labels)
const recomputed = result.purge?.recomputedEvents.length ?? 0
if (recomputed > 0) {
// 名次与积分会随成绩一起变,别只说「已删除」:说清重算了几个小项,操作员才不会
// 回头发现榜单变了却找不到原因。
const note = `已重算 ${recomputed} 个小项的排名与积分`
batchMessage.value = batchMessage.value === null ? note : `${batchMessage.value};${note}`
}
batchPurgeOpen.value = false
clearSelection()
await data.load(true)
} catch (caught) {
// 服务端原文(整批回滚、某个人被引用住了)必须照原样展示。
const message = caught instanceof Error ? caught.message : '批量彻底删除失败'
batchMessage.value = message
toast.error('批量彻底删除失败', message)
batchPurgeOpen.value = false
} finally {
batchPending.value = false
}
}
function emptyForm(): AthleteForm {
return { name: '', gender: '', age: '', teamId: '', employeeNo: '', idCard: '', bib: '', active: true }
}
@@ -261,10 +461,46 @@ async function confirmPurge(): Promise<void> {
<MxIcon name="warning" :size="14" /> {{ purgeError }}
</div>
<div v-if="batchMessage !== null" class="mx-badge mx-badge-error purge-error">
<MxIcon name="warning" :size="14" /> {{ batchMessage }}
</div>
<div v-if="selectedCount > 0" class="bd-batch-bar">
<span class="mx-badge mx-badge-neutral">已选 {{ selectedCount }} 人</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="clearSelection">清空选择</button>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || batchPending"
@click="askBatchDisable"
>
批量停用
</button>
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || batchPending"
@click="askBatchPurge"
>
批量彻底删除
</button>
<span class="bd-note">勾选范围是当前筛选结果,整批只发一次请求</span>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th class="bd-cell-mini">
<input
type="checkbox"
:checked="allFilteredSelected"
:indeterminate="someFilteredSelected"
:disabled="!data.canWrite.value || filteredIds.length === 0"
title="全选当前筛选结果"
@change="onHeaderCheck"
/>
</th>
<th>姓名</th>
<th class="bd-cell-mini">性别</th>
<th class="bd-cell-mini">年龄</th>
@@ -278,7 +514,7 @@ async function confirmPurge(): Promise<void> {
</thead>
<tbody>
<tr v-if="data.loading.value && data.athletes.value.length === 0">
<td colspan="9">
<td colspan="10">
<div class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">加载中…</div>
@@ -287,6 +523,15 @@ async function confirmPurge(): Promise<void> {
</tr>
<template v-else>
<tr v-for="athlete in filtered" :key="athlete.id">
<td class="bd-cell-mini">
<input
type="checkbox"
:checked="selectedIds.has(athlete.id)"
:disabled="!data.canWrite.value"
:aria-label="`选择 ${athlete.name}`"
@change="onRowCheck(athlete.id, $event)"
/>
</td>
<td>{{ athlete.name }}</td>
<td>{{ athlete.gender ?? '—' }}</td>
<td>{{ athlete.age ?? '—' }}</td>
@@ -325,7 +570,7 @@ async function confirmPurge(): Promise<void> {
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="9">
<td colspan="10">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
@@ -337,7 +582,7 @@ async function confirmPurge(): Promise<void> {
</table>
</div>
<p class="bd-note">
启用:正常出现在名单与报名选择里;停用为软删除:历史报名与成绩保留,新报名不再可选该运动员,随时可以再启用;彻底删除:真删除,人员档案连同他名下的报名与成绩一起从库里删掉、不可恢复,相关小项的排名与积分会按新排序重算。
启用:正常出现在名单与报名选择里;停用为软删除:历史报名与成绩保留,新报名不再可选该运动员,随时可以再启用;彻底删除:真删除,人员档案连同他名下的报名与成绩一起从库里删掉、不可恢复,相关小项的排名与积分会按新排序重算。表头勾选框会选中当前筛选结果里的全部人员,批量操作用一次请求完成。
</p>
</div>
@@ -375,10 +620,10 @@ async function confirmPurge(): Promise<void> {
<FormField label="工号 / 学号" hint="可留空,用于对账">
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
</FormField>
<FormField label="证件号" hint="证件号与号码布至少填一个,二者都是人员唯一标识">
<FormField label="证件号" hint="可选;不参与认人,但不能与别人重号">
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
</FormField>
<FormField label="号码布" hint="没有身份证信息时可只填号码布">
<FormField label="号码布" hint="认人依据;建议每人一条且全库唯一">
<input v-model="form.bib" class="mx-cell-input" placeholder="例如 101" />
</FormField>
<FormField label="状态" wide>
@@ -387,6 +632,33 @@ async function confirmPurge(): Promise<void> {
</div>
</FormDialog>
<ConfirmDialog
:open="batchDisableOpen"
tone="warning"
title="批量停用选中的运动员?"
:message="`停用 ${batchTargetIds.length} 人后,新报名不再可选他们;历史报名与成绩保留,随时可以再启用。`"
:details="[
'停用是软删除:报名、成绩、积分流水一条不动,榜单不变',
'整批只发一次请求,不会逐个消耗写操作配额',
]"
confirm-text="批量停用"
:pending="batchPending"
@confirm="confirmBatchDisable"
@cancel="batchDisableOpen = false"
/>
<ConfirmDialog
:open="batchPurgeOpen"
tone="danger"
title="批量彻底删除选中的运动员?"
:message="`将把这 ${batchTargetIds.length} 人从人员档案中永久删除,不可恢复。`"
:details="bulkPurgeDetails"
confirm-text="彻底删除"
:pending="batchPending"
@confirm="confirmBatchPurge"
@cancel="batchPurgeOpen = false"
/>
<ConfirmDialog
:open="confirmOpen"
tone="warning"
@@ -427,4 +699,14 @@ async function confirmPurge(): Promise<void> {
padding: 10px 12px;
line-height: 1.5;
}
.bd-batch-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
</style>
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
import type { EventCategory, EventCategoryInput, Gender } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { baseDataApi } from './baseDataApi'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import type { BaseDataTabKey } from './tabs'
@@ -13,6 +14,8 @@ const data = useBaseData()
const toast = useToastStore()
interface CategoryRow {
/** 服务端已有的组别 id;新增的行还没有 id。 */
id: number | null
key: number
name: string
// type="number" 的 v-model 会被 Vue 自动转成 number(空值仍是 ''),这里如实标注
@@ -39,11 +42,33 @@ const confirmOpen = ref(false)
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
const removedNames = computed(() => {
const current = new Set(rows.value.map((row) => row.name.trim()).filter((name) => name !== ''))
return original.value.filter((category) => !current.has(category.name)).map((category) => category.name)
const originalById = computed(() => new Map(original.value.map((category) => [category.id, category])))
/** 还留在表里的已有组别 id;用来判断这次保存有没有「删掉组别」。 */
const keptIds = computed(() => {
const ids = new Set<number>()
for (const row of rows.value) {
if (row.id !== null) ids.add(row.id)
}
return ids
})
/** 本次保存被移除的已有组别(按原始顺序,提示里直接报名字)。 */
const removedCategories = computed(() => original.value.filter((category) => !keptIds.value.has(category.id)))
const removedNames = computed(() => removedCategories.value.map((category) => category.name))
/** 新增的行(还没有服务端 id)。 */
const addedCount = computed(() => rows.value.filter((row) => row.id === null).length)
/**
* 本次保存是否动了组别的「结构」(新增或删除)。
*
* 结构变了只能走整表替换:组别 id 会重建,挂在旧 id 上的报名就断链了,
* 所以服务端在有报名数据时会拒绝。只改字段则走单行就地更新,不受报名数据限制。
*/
const structuralChange = computed(() => addedCount.value > 0 || removedCategories.value.length > 0)
watch(
[() => data.selectedEventId.value, () => data.categoriesOf(data.selectedEventId.value)],
() => {
@@ -57,6 +82,7 @@ watch(
function toRow(category: EventCategory): CategoryRow {
return {
id: category.id,
key: (nextKey += 1),
name: category.name,
multiplier: String(category.multiplier),
@@ -71,6 +97,7 @@ function addRow(): void {
rows.value = [
...rows.value,
{
id: null,
key: (nextKey += 1),
name: '',
multiplier: '1',
@@ -139,15 +166,45 @@ function validate(): boolean {
return Object.keys(next).length === 0
}
function buildInputs(): EventCategoryInput[] {
return rows.value.map((row, index) => ({
/** 一行 → 请求体;sortOrder 为 0 / 空时按行号兜底,与整表替换时的口径保持一致。 */
function rowInput(row: CategoryRow, index: number): EventCategoryInput {
return {
name: row.name.trim(),
multiplier: Number(row.multiplier),
gender: row.gender === '' ? null : row.gender,
ageMin: toAge(row.ageMin),
ageMax: toAge(row.ageMax),
sortOrder: Number(row.sortOrder) || index + 1,
}))
}
}
function buildInputs(): EventCategoryInput[] {
return rows.value.map((row, index) => rowInput(row, index))
}
function categoryDiffers(before: EventCategory, after: EventCategoryInput): boolean {
return (
before.name !== after.name ||
before.multiplier !== after.multiplier ||
(before.gender ?? null) !== after.gender ||
(before.ageMin ?? null) !== after.ageMin ||
(before.ageMax ?? null) !== after.ageMax ||
before.sortOrder !== after.sortOrder
)
}
/** 已有组别里真正被改动过的那些;没改的行不发请求,也少一次写配额。 */
function changedCategories(): { id: number; input: EventCategoryInput }[] {
const byId = originalById.value
const changes: { id: number; input: EventCategoryInput }[] = []
rows.value.forEach((row, index) => {
if (row.id === null) return
const before = byId.get(row.id)
if (before === undefined) return
const input = rowInput(row, index)
if (categoryDiffers(before, input)) changes.push({ id: row.id, input })
})
return changes
}
function requestSave(): void {
@@ -156,7 +213,8 @@ function requestSave(): void {
toast.error('组别校验未通过', '请先修正标红的字段')
return
}
if (removedNames.value.length > 0) {
// 增删组别才需要「整体替换」的二次确认;只改字段是就地更新,直接保存。
if (structuralChange.value && removedCategories.value.length > 0) {
confirmOpen.value = true
return
}
@@ -166,12 +224,24 @@ function requestSave(): void {
async function save(): Promise<void> {
const eventId = data.selectedEventId.value
if (eventId === null) return
const replace = structuralChange.value
saving.value = true
try {
await data.saveCategories(eventId, buildInputs())
toast.success('组别已保存')
if (replace) {
await data.saveCategories(eventId, buildInputs())
toast.success('组别已保存')
} else {
const changes = changedCategories()
for (const change of changes) {
await baseDataApi.events.updateCategory(eventId, change.id, change.input)
}
if (changes.length > 0) await data.loadEventDetail(eventId, true)
toast.success(changes.length === 0 ? '没有需要保存的改动' : `已更新 ${changes.length} 个组别`)
}
confirmOpen.value = false
} catch (caught) {
// 逐条更新可能只成功了前几条:失败后必须把服务端的真实状态拉回来,别让界面停在假象上。
if (!replace) await data.loadEventDetail(eventId, true)
toast.error('保存组别失败', caught instanceof Error ? caught.message : null)
} finally {
saving.value = false
@@ -195,7 +265,7 @@ async function save(): Promise<void> {
:disabled="!data.canWrite.value || selectedEvent === null || saving"
@click="requestSave"
>
{{ saving ? '保存中…' : '保存全部' }}
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
@@ -211,8 +281,9 @@ async function save(): Promise<void> {
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
保存会整体替换该小项的组别配置(PUT /api/events/{{ selectedEvent.id }}/categories)。
若该项目已有报名数据,服务端会拒绝替换。
已有报名数据的小项:改名 / 改年龄 / 改性别 / 改权重会就地保存,报名与成绩一条不动;
其中<strong>改权重会立即按新权重重算该项目已发布成绩的积分与榜单</strong>,改名 / 改年龄 / 改性别不会。
新增或删除组别会被服务端拒绝——组别一重建,挂在它下面的报名就断链了。
</span>
</div>
@@ -287,7 +358,7 @@ async function save(): Promise<void> {
:open="confirmOpen"
tone="danger"
title="保存将移除组别?"
:message="`本次保存会删除 ${removedNames.length} 个组别,并整体替换该小项的组别配置。`"
:message="`本次保存会删除 ${removedCategories.length} 个组别,并整体替换该小项的组别配置。若该项目已有报名数据,服务端会拒绝这次增删——改名 / 改年龄 / 改性别 / 改权重请先撤掉删除操作再保存。`"
:details="removedNames"
confirm-text="确认保存"
:pending="saving"
@@ -438,7 +438,7 @@ async function submitTeam(): Promise<void> {
</div>
<p class="bd-note">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</p>
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
<p class="bd-note">报名会自动带上该运动员的证件号与号码布(认人看号码布);种子等字段仍通过下方报名表导入填写。</p>
<p class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
</template>
</template>
@@ -193,8 +193,10 @@ function downloadTemplate(): void {
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
证件号与号码布都是人员唯一标识:
先按证件号匹配,未命中再按号码布匹配,仍未命中则按「姓名 + 单位」匹配库里证件号为空的人并回填。
号码布是人员的唯一标识:先按号码布匹配;号码布为空或没匹配到,再按「姓名 + 单位」找——
单位内恰好一人且对方还没有号码布就命中并回填;恰好一人但对方已有不同的号码布,按「同名的另一个人」新建档案
(结果里会单独列出,不报错);多人则算歧义,列入冲突逐条决定。证件号是可选档案信息,只写入 / 更新、不参与匹配,
但一个人的证件号不能与库里另一个人相同,撞号会明确报错并指出占用者。
</span>
</div>
@@ -262,11 +264,26 @@ function downloadTemplate(): void {
</div>
<p v-if="result.errors.length === 0 && conflicts.length === 0" class="bd-note">全部行导入成功,没有错误行。</p>
<div v-if="result.sameNameNewPersons.length > 0" class="same-name">
<div class="errors-head">
<span class="mx-badge mx-badge-warning">
以下 {{ result.sameNameNewPersons.length }} 人按同名的另一个人新建了档案(号码布不同)
</span>
<span class="bd-note">这些行已导入成功,仅提醒核对;若号码布填错,请到「运动员」里改回并删除多余档案。</span>
</div>
<ul class="error-list">
<li v-for="(person, index) in result.sameNameNewPersons" :key="index">
{{ person.name }}({{ person.teamCode }}):原有号码布 {{ person.existingBib }} → 新号码布 {{ person.newBib }}
</li>
</ul>
</div>
<section v-if="conflicts.length > 0" class="conflicts">
<div class="conflicts-head">
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
<span class="bd-note">
同一证件号 / 号码布在库里与报名表里的姓名 / 性别 / 年龄 / 单位 / 号码布不一致,本轮未落库,也不建报名。
号码布(或姓名 + 单位)命中了库里已有人员,但姓名 / 性别 / 年龄 / 单位 / 号码布与表里不一致,
本轮未落库,也不建报名。
</span>
</div>
<p class="bd-note">逐条选择保留哪一侧;未决策的冲突会一直留在列表里,不会静默丢弃。</p>
@@ -447,6 +464,9 @@ function downloadTemplate(): void {
margin-bottom: 8px;
flex-wrap: wrap;
}
.same-name {
margin-bottom: 14px;
}
.error-list {
display: flex;
flex-direction: column;
@@ -4,6 +4,7 @@ import type { RecordBonusMode, ScoringProfile, ScoringProfileInput, TieMode } fr
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormField from './FormField.vue'
import { liveOnPublishOf } from '../system/systemApi'
import { parseIndividualToTeamWeight, useBaseData } from './useBaseData'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
@@ -79,6 +80,45 @@ watch(
{ immediate: true },
)
// ── 分批结算方式:全局设置,同样只写 standings 子对象 ────────────────────
const liveOnPublish = ref(true)
const liveSaving = ref(false)
const liveError = ref<string | null>(null)
/** 库里现在的结算方式;settings 仅管理员可见,非管理员拿到 null。 */
const storedLiveOnPublish = computed<boolean | null>(() =>
data.settings.value === null ? null : liveOnPublishOf(data.settings.value),
)
const liveDirty = computed(
() => storedLiveOnPublish.value !== null && liveOnPublish.value !== storedLiveOnPublish.value,
)
// 服务端设置变化(别处保存 / 规则包导入)时回填;本地已改动的勾选不被覆盖。
watch(
() => data.settings.value,
(value) => {
if (value === null) return
if (!liveDirty.value) liveOnPublish.value = liveOnPublishOf(value)
},
{ immediate: true },
)
async function saveLiveOnPublishSetting(): Promise<void> {
liveSaving.value = true
liveError.value = null
try {
await data.saveLiveOnPublish(liveOnPublish.value)
toast.success(
'结算方式已保存',
liveOnPublish.value ? '每发布一批就重算名次与榜单' : '等该组全部录完后才更新名次与榜单',
)
} catch (caught) {
liveError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
liveSaving.value = false
}
}
async function saveWeight(): Promise<void> {
const value = parsedWeight.value
if (value === null) {
@@ -441,6 +481,30 @@ const totalTop = computed(() => {
placeholder="1"
/>
</FormField>
<FormField
label="分批发布时实时更新榜单"
:error="liveError"
hint="开(默认):每发布一批成绩就重算名次、各单位总分与奖牌榜。关掉 = 延迟结算:发布只落成绩,等该项目该组所有批次都录完才一起更新名次与榜单;没录完时可在成绩录入页点「立即结算」。"
>
<div class="settle-row">
<label class="bd-check">
<input
v-model="liveOnPublish"
type="checkbox"
:disabled="!isAdmin || liveSaving"
/>
发布后立即结算
</label>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!isAdmin || !liveDirty || liveSaving"
@click="saveLiveOnPublishSetting"
>
{{ liveSaving ? '保存中…' : '保存结算方式' }}
</button>
</div>
</FormField>
</div>
<p class="bd-note">
这是<strong>全局</strong>设置(standings.individual_to_team_weight),不随某一张积分模板保存,所有模板与所有计分通道共用:
@@ -512,4 +576,10 @@ const totalTop = computed(() => {
.rules-table {
max-width: 420px;
}
.settle-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
</style>
@@ -18,6 +18,7 @@ import ConfirmDialog from '../ConfirmDialog.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { EVENT_ROW_DBLCLICK_TITLE, openEventCategories } from './eventRowActions'
import { useToastStore } from '../../stores/toast'
import type { BaseDataTabKey } from './tabs'
@@ -26,6 +27,14 @@ const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
const data = useBaseData()
const toast = useToastStore()
/** 双击小项条目:先选中它(两次单击不会抢走最终选中),再跳到「组别」分区。 */
function openCategories(eventId: number): void {
openEventCategories(eventId, {
selectEvent: (id) => void data.selectEvent(id),
goTab: (tab) => emit('goTab', tab),
})
}
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,24}$/
const EVENT_TYPE_OPTIONS: Array<{ value: EventType; label: string }> = [
@@ -523,7 +532,9 @@ async function confirmDeleteEvent(): Promise<void> {
:key="event.id"
:class="{ selected: event.id === data.selectedEventId.value }"
class="clickable"
:title="EVENT_ROW_DBLCLICK_TITLE"
@click="data.selectEvent(event.id)"
@dblclick="openCategories(event.id)"
>
<td class="bd-mono">{{ event.code }}</td>
<td>{{ event.name }}</td>
@@ -539,7 +550,7 @@ async function confirmDeleteEvent(): Promise<void> {
<td class="right bd-mono">{{ event.multiplier }}</td>
<td>{{ labelOf(STATUS_OPTIONS, event.status) }}</td>
<td>
<div class="bd-actions">
<div class="bd-actions" @dblclick.stop>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click.stop="openEditEvent(event)">
编辑
</button>
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { ApiError, adminApi, mockMode, mockReason } from '../../api/client'
import type { AthletePurgeResult } from '@/shared/api'
import type { AthleteBatchAction, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
import type {
Athlete,
AthleteInput,
@@ -123,6 +123,18 @@ export const baseDataApi = {
() => adminApi.client.events.saveCategories(eventId, inputs),
() => mockBaseData.saveCategories(eventId, inputs),
),
/**
* 单个组别的就地更新。
*
* 与 saveCategories 一样可以降级到示例数据(现场断网时页面的「示例数据」标记已经说明了一切),
* 但不让 404 降级:404 是「组别已不在」的真实回答,落到示例数据上会把「没改到」显示成「已更新」。
*/
updateCategory: (eventId: number, categoryId: number, input: EventCategoryInput): Promise<EventCategory[]> =>
call(
() => adminApi.client.events.updateCategory(eventId, categoryId, input),
() => mockBaseData.updateCategory(eventId, categoryId, input),
{ fallbackOn404: false },
),
saveChannels: (eventId: number, inputs: ScoreChannelInput[]): Promise<ScoreChannel[]> =>
call(
() => adminApi.client.events.saveChannels(eventId, inputs),
@@ -158,6 +170,16 @@ export const baseDataApi = {
call(() => adminApi.purgeAthlete(id), () => mockBaseData.purgeAthlete(id), {
fallbackOn404: false,
}),
/**
* 批量停用 / 批量彻底删除:一个请求完成整批,逐条成败在响应里。
*
* 与 purge 同理不让 404 降级:404 是「这批人已经被别人处理过」的真实回答,
* 落回示例数据会把「一个都没删掉」显示成「删掉了」。
*/
batch: (action: AthleteBatchAction, ids: number[], dryRun = false): Promise<AthleteBatchResult> =>
call(() => adminApi.client.athletes.batch(action, ids, dryRun), () => mockBaseData.batchAthletes(action, ids, dryRun), {
fallbackOn404: false,
}),
},
entries: {
@@ -317,19 +317,25 @@ function formatFieldValue(value: string | number | null | undefined): string {
}
/**
* 冲突的决策键:证件号优先,没有证件号时用号码布。
* 冲突的决策键:号码布优先,号码布为空再回落证件号,两样都没有时回落姓名 + 单位。
*
* 与服务端 repo_import.go 的 decisionKey() 是同一个约定,两边必须保持一致;
* 只有号码布的行证件号为空串,直接拿证件号当键会让多条冲突互相覆盖。
* 号码布才是人员的唯一标识;证件号自「号码布优先匹配」之后不再参与匹配,
* 同一份表里两个不同的人可以共用一个证件号,拿它当键会让两条冲突共用一行决策
* (面板 :key 重复、勾选互相串)。
*
* 这个键只管面板内的分组与勾选:发给服务端的载荷带的是 idCard + bib 两个字段,
* 服务端按自己的 decisionKey()(repo_import.go)重算,键本身不出现在载荷里。
*/
export function decisionKeyOf(conflict: ImportConflict): string {
return conflict.idCard !== '' ? conflict.idCard : conflict.bib
if (conflict.bib !== '') return conflict.bib
if (conflict.idCard !== '') return conflict.idCard
return `${conflict.incoming.name}·${conflict.incoming.teamCode}`
}
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
export function conflictLabel(conflict: ImportConflict): string {
const key = decisionKeyOf(conflict)
return key === '' ? '(无证件号 / 号码布)' : key
return key === '' ? '(无号码布 / 证件号 / 姓名)' : key
}
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
@@ -0,0 +1,31 @@
/**
* 小项列表「双击条目 → 跳到该小项的组别」的动作(纯函数)。
*
* 抽出来的原因:浏览器在 dblclick 之前必然先派发两次 click,两次都会改选中项,
* 「最终选中的到底是不是被双击的那一项」最容易写错;`.vue` 组件跑不进 node --test,
* 所以把「先选中 → 再跳分区」的顺序与目标放这里断言。
*/
import type { BaseDataTabKey } from './tabs'
/** 行上 title 里的可发现性文案:单击只选中,双击才跳转,不写出来没人会去试。 */
export const EVENT_ROW_DBLCLICK_TITLE = '双击查看该小项的组别'
export interface EventRowActionDeps {
/** 选中该小项(useBaseData.selectEvent)。 */
selectEvent: (id: number) => void
/** 跳转到基础数据页的某个分区。 */
goTab: (tab: BaseDataTabKey) => void
}
/**
* 双击小项条目:先选中这一项,再跳到「组别」分区。
*
* 这里必须自己再选一次:dblclick 之前的两次 click 已经改过选中项(虽然就是同一行,
* 但顺序上不能依赖它),补一次同 id 的选中才能保证跳过去的是被双击的那一项。
* 两次 click 之间不引入任何延时,单击选中既有的即时手感不变。
*/
export function openEventCategories(eventId: number, deps: EventRowActionDeps): void {
deps.selectEvent(eventId)
deps.goTab('categories')
}
+245 -91
View File
@@ -1,5 +1,5 @@
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
import type { AthletePurgeResult } from '@/shared/api'
import type { AthleteBatchAction, AthleteBatchItem, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
import type {
Athlete,
AthleteInput,
@@ -12,6 +12,7 @@ import type {
ImportConflict,
ImportConflictDecision,
ImportConflictField,
SameNameNewPerson,
ScoringProfile,
ScoringProfileInput,
ScoreChannel,
@@ -187,6 +188,37 @@ const IMPORT_COLUMNS = MANDATORY_COLUMNS
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
/**
* 冲突决策键:与服务端一致,证件号优先、为空时取号码布。
*
* 载荷里 idCard 与 bib 两个字段都带,服务端按自己的口径重算键(repo_import.go 的 decisionKey()),
* 所以这里必须与服务端同序,否则决策会落到另一条冲突上。
*/
function importDecisionKey(key: { idCard: string; bib: string }): string {
return key.idCard !== '' ? key.idCard : key.bib
}
/** 报名表里一行的人员信息(姓名已确定非空),供匹配逻辑使用。 */
interface ImportRow {
line: number
name: string
gender: Athlete['gender']
age: number | null
idCard: string
bib: string
team: Team
}
/**
* 人员匹配结果:冲突行不落库、等用户决策;error 是可直接回显的行级报错文案。
* sameNameNewPerson 只在「号码布不同 → 按同名的另一个人新建」时出现,仅作报告。
*/
type AthleteMatch =
| { kind: 'reused' }
| { kind: 'conflict'; conflict: ImportConflict }
| { kind: 'created'; sameNameNewPerson?: SameNameNewPerson }
| { kind: 'error'; message: string }
/** 导出类本身供测试构造隔离实例;页面用下方单例。 */
export class MockBaseData {
private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team }))
@@ -365,7 +397,9 @@ export class MockBaseData {
saveCategories(eventId: number, inputs: EventCategoryInput[]): EventCategory[] {
this.getEvent(eventId)
if ((this.entryCounts.get(eventId) ?? 0) > 0) throw new Error('该项目已有报名数据,不能整体替换组别')
if ((this.entryCounts.get(eventId) ?? 0) > 0) {
throw new Error('该项目已有报名数据,不能新增或删除组别;改名、改年龄、改性别、改权重可以直接保存')
}
const names = new Set<string>()
for (const input of inputs) {
const name = input.name.trim()
@@ -380,6 +414,33 @@ export class MockBaseData {
return this.getEvent(eventId).categories ?? []
}
/**
* 就地更新单个组别:与真实服务端一致,id 不变、只改字段,所以有报名数据也允许改。
*
* 增删组别仍走 saveCategories(示例数据里同样会被报名数据挡住)。
* 真实服务端在权重变化时会重算已发布成绩的积分与榜单,示例数据没有建模流水与榜单,
* 没有可重算的东西——页面照样能拿到更新后的组别列表。
*/
updateCategory(eventId: number, categoryId: number, input: EventCategoryInput): EventCategory[] {
this.getEvent(eventId)
const index = this.categories.findIndex((item) => item.id === categoryId && item.eventId === eventId)
if (index < 0) throw new Error('组别不存在')
const name = input.name.trim()
if (name === '') throw new Error('组别名称必填')
if (this.categories.some((item) => item.eventId === eventId && item.id !== categoryId && item.name === name)) {
throw new Error(`组别名称重复:${name}`)
}
this.categories[index] = {
...input,
name,
// 服务端口径:权重 <= 0 一律按 1 存。
multiplier: input.multiplier > 0 ? input.multiplier : 1,
id: categoryId,
eventId,
}
return this.getEvent(eventId).categories ?? []
}
saveChannels(eventId: number, inputs: ScoreChannelInput[]): ScoreChannel[] {
this.getEvent(eventId)
const combinations = new Set<string>()
@@ -498,6 +559,39 @@ export class MockBaseData {
}
}
/**
* 批量停用 / 批量彻底删除:与真实服务端同形,逐条回报成败。
*
* 不存在的 id 按 `NOT_FOUND` 单条失败,不能整批失败也不能静默跳过;dryRun 只回报不改数据。
* 示例数据没有把报名、成绩与流水建模到具体人员名下(只有每个小项的报名条数),
* 所以彻底删除的统计都是 0、也没有小项需要重算——与单条 purgeAthlete 的口径一致。
*/
batchAthletes(action: AthleteBatchAction, ids: number[], dryRun = false): AthleteBatchResult {
const items: AthleteBatchItem[] = ids.map((id) => {
if (this.athletes.some((athlete) => athlete.id === id)) return { id, ok: true }
return { id, ok: false, code: 'NOT_FOUND', message: '运动员不存在' }
})
if (!dryRun) {
const found = new Set(ids)
this.athletes =
action === 'disable'
? this.athletes.map((athlete) => (found.has(athlete.id) ? { ...athlete, active: false } : athlete))
: this.athletes.filter((athlete) => !found.has(athlete.id))
}
return {
action,
...(dryRun ? { dryRun: true } : {}),
requested: ids.length,
succeeded: items.filter((item) => item.ok).length,
failed: items.filter((item) => !item.ok).length,
items,
purge:
action === 'purge'
? { deletedEntries: 0, deletedResults: 0, deletedLedger: 0, deletedBreaks: 0, recomputedEvents: [] }
: undefined,
}
}
// ── 报名导入 ──────────────────────────────────────────────────────────────
async importEntries(file: File): Promise<EntryImportResult> {
@@ -506,14 +600,11 @@ export class MockBaseData {
/** 冲突决策重跑:没给决策的键仍视为未决策,继续留在返回的 conflicts 里。 */
async resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> {
// 与服务端一致:证件号优先,没有证件号时用号码布当决策键。
const keyOf = (decision: ImportConflictDecision): string =>
decision.idCard !== '' ? decision.idCard : decision.bib
return this.runImport(file, new Map(decisions.map((decision) => [keyOf(decision), decision.action])))
return this.runImport(file, new Map(decisions.map((decision) => [importDecisionKey(decision), decision.action])))
}
private async runImport(file: File, decisions: Map<string, ConflictAction>): Promise<EntryImportResult> {
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [] })
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [], sameNameNewPersons: [] })
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
if (extension !== '.csv') return empty([XLSX_HINT])
const rows = parseCsv(await file.text())
@@ -524,8 +615,7 @@ export class MockBaseData {
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
}
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [] }
const seenIdCards = new Set<string>()
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [], sameNameNewPersons: [] }
const seenBibs = new Set<string>()
rows.slice(1).forEach((row, offset) => {
const line = offset + 2
@@ -578,114 +668,171 @@ export class MockBaseData {
result.errors.push(`第 ${line} 行:有姓名的行必须填证件号或号码布`)
return
}
// 年龄可空;填了就必须是 0–120 的整数,非法行直接跳过,不占证件号 / 号码布名额。
// 年龄可空;填了就必须是 0–120 的整数,非法行直接跳过,不占号码布名额。
const age = parseAge(cell('age'))
if (age !== null && (!Number.isInteger(age) || age < 0 || age > 120)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:年龄必须是 0–120 的整数`)
return
}
if (idCard !== '' && seenIdCards.has(idCard)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:证件号 ${idCard} 在文件中重复出现`)
return
}
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
// 证件号只是可选档案信息,同一份表内重复不再拦截(与服务端口径一致)。
if (bib !== '' && seenBibs.has(bib)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`)
return
}
if (idCard !== '') seenIdCards.add(idCard)
if (bib !== '') seenBibs.add(bib)
const rawGender = cell('gender')
const gender = rawGender === '男' || rawGender === '女' ? rawGender : null
// 契约匹配顺序:证件号 → 号码布 → 姓名 + 单位(库里证件号为空)。
const matched =
(idCard === '' ? undefined : this.athletes.find((athlete) => athlete.idCard === idCard)) ??
(bib === '' ? undefined : this.athletes.find((athlete) => athlete.bib === bib))
if (matched !== undefined) {
const conflict = this.detectConflict(matched, line, idCard, bib, athleteName, gender, age, team)
const key = idCard !== '' ? idCard : bib
if (conflict !== null && decisions.get(key) === undefined) {
result.conflicts.push(conflict)
return
}
if (conflict !== null && decisions.get(key) === 'keep-new') {
matched.name = athleteName
if (gender !== null) matched.gender = gender
if (age !== null) matched.age = age
matched.teamId = team.id
if (idCard !== '') matched.idCard = idCard
if (bib !== '') matched.bib = bib
}
// 库里没有年龄时按表里的值回填,已有年龄一律不动。
if (age !== null && matched.age === null) matched.age = age
result.created += 1
const match = this.matchAthlete({ line, name: athleteName, gender, age, idCard, bib, team }, decisions)
if (match.kind === 'conflict') {
result.conflicts.push(match.conflict)
return
}
// 证件号 / 号码布未命中,但 (姓名, 单位) 命中且库里证件号为空:视为同一人并回填。
const legacy = this.athletes.find(
(athlete) =>
athlete.name === athleteName &&
athlete.teamId === team.id &&
(athlete.idCard === null || athlete.idCard === ''),
)
if (legacy !== undefined) {
const conflict = this.detectConflict(legacy, line, idCard, bib, athleteName, gender, age, team)
const legacyKey = idCard !== '' ? idCard : bib
if (conflict !== null && decisions.get(legacyKey) === undefined) {
result.conflicts.push(conflict)
return
}
if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && bib !== '') legacy.bib = bib
if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && age !== null) legacy.age = age
if (idCard !== '') legacy.idCard = idCard
if (bib !== '' && (legacy.bib === null || legacy.bib === '')) legacy.bib = bib
// 其余情况只补库中为空的年龄,绝不用空值清掉已有值。
if (age !== null && legacy.age === null) legacy.age = age
result.created += 1
if (match.kind === 'error') {
result.skipped += 1
result.errors.push(`第 ${line} 行:${match.message}`)
return
}
this.athletes.push({
id: (this.nextAthleteId += 1),
name: athleteName,
gender,
teamId: team.id,
employeeNo: null,
idCard: idCard === '' ? null : idCard,
bib: bib === '' ? null : bib,
age,
active: true,
})
if (match.kind === 'created' && match.sameNameNewPerson !== undefined) {
result.sameNameNewPersons.push(match.sameNameNewPerson)
}
result.created += 1
})
return result
}
/** name / gender / age / teamCode / bib 与库里不一致才算冲突;表里为空的字段视为未指定。 */
private detectConflict(
athlete: Athlete,
row: number,
idCard: string,
bib: string,
name: string,
gender: Athlete['gender'],
age: number | null,
team: Team,
): ImportConflict | null {
/**
* 人员匹配:号码布精确命中 → 姓名 + 单位 → 新建,与真实服务端 repo_import.go 同序。
*
* 证件号不参与匹配(只是可选档案信息),只在新建 / 回填 / keep-new 时写入;
* 撞到别人的证件号仍然是行级报错,不允许静默改库或静默跳过。
*/
private matchAthlete(row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
if (row.bib !== '') {
const byBib = this.athletes.find((athlete) => athlete.bib === row.bib)
if (byBib !== undefined) return this.reuseOrConflict(byBib, row, decisions)
}
const candidates = this.athletes.filter(
(athlete) => athlete.name === row.name && athlete.teamId === row.team.id,
)
if (candidates.length === 0) return this.createImportedAthlete(row)
const first = candidates[0] as Athlete
if (candidates.length > 1) {
// 同名同单位有多个人:不能替用户挑人,没决策就进面板(与服务端一样以第一条候选为代表)。
// 字段全对得上时也拦:这里认不出「是哪一个人」,猜错就会改错档案。
if (decisions.has(importDecisionKey(row))) return this.reuseOrConflict(first, row, decisions)
return { kind: 'conflict', conflict: this.buildConflict(first, row, this.changedFields(first, row)) }
}
// 表里也给了号码布、且与库里不同 → 同名的另一个人:新建并报告,不拦截导入。
if (first.bib !== null && first.bib !== '' && row.bib !== '' && first.bib !== row.bib) {
const created = this.createImportedAthlete(row)
if (created.kind === 'created') {
created.sameNameNewPerson = {
name: row.name,
teamCode: row.team.code,
newBib: row.bib,
existingBib: first.bib,
}
}
return created
}
return this.reuseOrConflict(first, row, decisions)
}
/** 命中已有人员:无差异直接复用并回填空字段;有差异时按决策落库,没决策就进冲突面板。 */
private reuseOrConflict(ex: Athlete, row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
const conflict = this.conflictFor(ex, row)
if (conflict === null) {
const taken = this.backfillAthlete(ex, row)
return taken === null ? { kind: 'reused' } : { kind: 'error', message: taken }
}
const action = decisions.get(importDecisionKey(conflict))
if (action === undefined) return { kind: 'conflict', conflict }
// keep-existing:人员字段保持库里的值;keep-new:用表里的字段覆盖(表里没填的保留)。
if (action === 'keep-new') {
const taken = this.idCardTakenError(row.idCard, ex.id)
if (taken !== null) return { kind: 'error', message: taken }
ex.name = row.name
if (row.gender !== null) ex.gender = row.gender
if (row.age !== null) ex.age = row.age
ex.teamId = row.team.id
if (row.idCard !== '') ex.idCard = row.idCard
if (row.bib !== '') ex.bib = row.bib
}
return { kind: 'reused' }
}
/** 只补库中为空的证件号 / 号码布 / 年龄,已有值不动;证件号撞别人时回报行级错误。 */
private backfillAthlete(ex: Athlete, row: ImportRow): string | null {
if (row.idCard !== '' && (ex.idCard === null || ex.idCard === '')) {
const taken = this.idCardTakenError(row.idCard, ex.id)
if (taken !== null) return taken
ex.idCard = row.idCard
}
if (row.bib !== '' && (ex.bib === null || ex.bib === '')) ex.bib = row.bib
if (row.age !== null && ex.age === null) ex.age = row.age
return null
}
/** 新建人员档案;证件号撞到别人时只回报错误,不落库。 */
private createImportedAthlete(row: ImportRow): AthleteMatch {
const taken = this.idCardTakenError(row.idCard, 0)
if (taken !== null) return { kind: 'error', message: taken }
this.athletes.push({
id: (this.nextAthleteId += 1),
name: row.name,
gender: row.gender,
teamId: row.team.id,
employeeNo: null,
idCard: row.idCard === '' ? null : row.idCard,
bib: row.bib === '' ? null : row.bib,
age: row.age,
active: true,
})
return { kind: 'created' }
}
/**
* 证件号不参与匹配,但仍一人一号:撞到别人时报出占用者是谁。
* 文案与服务端 idCardTakenError 一致,现场在降级模式与真实服务端看到的是同一句话。
*/
private idCardTakenError(idCard: string, excludeId: number): string | null {
if (idCard === '') return null
const taken = this.athletes.find((athlete) => athlete.idCard === idCard && athlete.id !== excludeId)
if (taken === undefined) return null
const bib = taken.bib === null || taken.bib === '' ? '未填' : taken.bib
return `证件号 ${idCard} 已被另一个人占用(姓名 ${taken.name},号码布 ${bib})`
}
/** 把「库中人员 + 表里这一行」包成冲突对象,没有差异时返回 null。 */
private conflictFor(athlete: Athlete, row: ImportRow): ImportConflict | null {
const changed = this.changedFields(athlete, row)
return changed.length === 0 ? null : this.buildConflict(athlete, row, changed)
}
/** name / gender / age / teamCode / bib 与库里不一致才算差异;表里为空的字段视为未指定。 */
private changedFields(athlete: Athlete, row: ImportRow): ImportConflictField[] {
const changed: ImportConflictField[] = []
if (athlete.name !== name) changed.push('name')
if (gender !== null && athlete.gender !== gender) changed.push('gender')
if (athlete.name !== row.name) changed.push('name')
if (row.gender !== null && athlete.gender !== row.gender) changed.push('gender')
// 年龄两侧都有值且不同才算差异;旧数据没有年龄时不能把所有人标成冲突。
if (age !== null && athlete.age !== null && athlete.age !== age) changed.push('age')
if (athlete.teamId !== team.id) changed.push('teamCode')
if (row.age !== null && athlete.age !== null && athlete.age !== row.age) changed.push('age')
if (athlete.teamId !== row.team.id) changed.push('teamCode')
// 号码布两侧都给了且不一致才算差异;表里没给视为不修改。
if (bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== bib) changed.push('bib')
if (changed.length === 0) return null
if (row.bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== row.bib) {
changed.push('bib')
}
return changed
}
/** 冲突对象;changed 为空也算冲突(同名同单位认不出是哪一个,同样要进面板等决策)。 */
private buildConflict(athlete: Athlete, row: ImportRow, changed: ImportConflictField[]): ImportConflict {
return {
row,
idCard,
bib,
row: row.line,
idCard: row.idCard,
bib: row.bib,
athleteId: athlete.id,
existing: {
name: athlete.name,
@@ -695,7 +842,14 @@ export class MockBaseData {
employeeNo: athlete.employeeNo,
bib: athlete.bib,
},
incoming: { name, gender, age, teamCode: team.code, employeeNo: null, bib: bib === '' ? null : bib },
incoming: {
name: row.name,
gender: row.gender,
age: row.age,
teamCode: row.team.code,
employeeNo: null,
bib: row.bib === '' ? null : row.bib,
},
changedFields: changed,
}
}
@@ -287,6 +287,23 @@ async function saveIndividualToTeamWeight(weight: number): Promise<EventSettings
return saved
}
/**
* 保存「分批发布时是否实时更新榜单」(standings.live_on_publish,服务端字段 liveOnPublish)。
*
* 与折算系数一样只提交完整的 standings 子对象:漏传的字段会被服务端保持原值,
* 但整体回传能保证前端拿到的设置与服务端口径一致。shared 的 EventSettings 还没带这个字段,
* 所以这里按扩展字段拼装;回写 event store 后开关与录入页提示立刻同步。
*/
async function saveLiveOnPublish(live: boolean): Promise<EventSettings> {
const eventStore = useEventStore()
const current = eventStore.settings
if (current === null) throw new Error('赛事设置未加载,无法保存结算方式')
const standings = { ...current.standings, liveOnPublish: live }
const saved = await adminApi.client.settings.update({ standings })
eventStore.settings = saved
return saved
}
export interface BaseDataController {
teams: Ref<Team[]>
sports: Ref<Sport[]>
@@ -329,6 +346,8 @@ export interface BaseDataController {
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
/** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */
saveIndividualToTeamWeight: (weight: number) => Promise<EventSettings>
/** 只改「分批发布是否实时更新榜单」;保存后同步 event store。 */
saveLiveOnPublish: (live: boolean) => Promise<EventSettings>
}
export function useBaseData(): BaseDataController {
@@ -377,5 +396,6 @@ export function useBaseData(): BaseDataController {
importEntries,
resolveImportConflicts,
saveIndividualToTeamWeight,
saveLiveOnPublish,
}
}
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import MxIcon from '../MxIcon.vue'
import { describeError, sessionSettingsApi } from './systemApi'
import {
SESSION_HOURS_FALLBACK,
describeSessionLifetime,
formatSessionExpiry,
validateSessionHours,
type SessionHoursBounds,
} from './sessionSettings'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
/**
* 登录有效期(GET/PUT /api/system/session)。
*
* 会话寿命是机器级配置:它决定这台机器上所有人的登录能待多久,
* 所以放在「系统设置」里,而不是某个赛事的信息页。
* admin-only(服务端两个接口都限 admin),非 admin 不挂载本面板。
*/
const auth = useAuthStore()
const toast = useToastStore()
const input = ref<string>('')
const saved = ref<number | null>(null)
const bounds = ref<SessionHoursBounds>({
minHours: SESSION_HOURS_FALLBACK.minHours,
maxHours: SESSION_HOURS_FALLBACK.maxHours,
})
const loading = ref(true)
const saving = ref(false)
const loadError = ref<string | null>(null)
const saveError = ref<string | null>(null)
/** 当前会话的到期时间优先取 /auth/me(登录时与心跳时都会刷新),拿不到再退回本接口下发的值。 */
const expiresAt = computed(() => auth.sessionExpiresAt ?? remoteExpiresAt.value)
const remoteExpiresAt = ref<string | null>(null)
const expiryText = computed(() => formatSessionExpiry(expiresAt.value))
const check = computed(() => validateSessionHours(input.value, bounds.value))
const checkOk = computed(() => check.value.ok)
const checkMessage = computed(() => (check.value.ok ? null : check.value.message))
const candidateHours = computed(() => (check.value.ok ? check.value.hours : null))
const dirty = computed(() => candidateHours.value !== null && candidateHours.value !== saved.value)
const hint = computed(() =>
candidateHours.value === null
? '登录后能待多久:有效期内继续操作会自动顺延,不用中途重新登录。'
: describeSessionLifetime(candidateHours.value),
)
onMounted(() => {
void load()
})
async function load(): Promise<void> {
loading.value = true
try {
const settings = await sessionSettingsApi.load()
bounds.value = { minHours: settings.minHours, maxHours: settings.maxHours }
saved.value = settings.sessionHours
input.value = String(settings.sessionHours)
remoteExpiresAt.value = settings.expiresAt
loadError.value = null
} catch (caught) {
loadError.value = describeError(caught)
input.value = input.value === '' ? String(SESSION_HOURS_FALLBACK.defaultHours) : input.value
} finally {
loading.value = false
}
}
async function save(): Promise<void> {
const hours = candidateHours.value
if (hours === null) {
saveError.value = checkMessage.value
return
}
saving.value = true
saveError.value = null
try {
const settings = await sessionSettingsApi.save(hours)
saved.value = settings.sessionHours
input.value = String(settings.sessionHours)
remoteExpiresAt.value = settings.expiresAt
toast.success('登录有效期已保存', '对之后的新登录生效,已登录的会话不受影响')
} catch (caught) {
saveError.value = describeError(caught)
} finally {
saving.value = false
}
}
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">登录有效期</span>
<div class="head-actions">
<span v-if="dirty" class="mx-badge mx-badge-warning">未保存</span>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="loading || saving || !checkOk || !dirty"
@click="save"
>
<MxIcon name="check" :size="14" /> {{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<div class="mx-card-body">
<p class="notice">{{ expiryText }}</p>
<p v-if="loadError !== null" class="notice error">读取失败:{{ loadError }}</p>
<p v-if="saveError !== null" class="notice error">{{ saveError }}</p>
<div class="form-grid">
<div class="field">
<label class="mx-label" for="session-hours">登录有效期(小时)</label>
<div class="mx-input-wrap">
<input
id="session-hours"
v-model="input"
class="mx-input"
type="number"
:min="bounds.minHours"
:max="bounds.maxHours"
step="1"
:disabled="loading || saving"
/>
</div>
<span class="field-hint">
范围 {{ bounds.minHours }}~{{ bounds.maxHours }} 小时(最长 30 天),默认
{{ SESSION_HOURS_FALLBACK.defaultHours }} 小时。
</span>
</div>
<div class="field">
<span class="mx-label">说明</span>
<p class="hint-text" :class="{ invalid: !checkOk }">{{ hint }}</p>
<p v-if="checkMessage !== null" class="field-hint error">{{ checkMessage }}</p>
</div>
</div>
<p class="notice">
只对之后的新登录生效:已经登录的会话保持各自的到期时间,不会被踢下线;
有效期内继续操作会自动顺延到期时间,因此从早用到晚不会中途掉线。
</p>
</div>
</section>
</template>
<style scoped>
.head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mx-card-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
}
.field-hint {
margin-top: 6px;
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
.field-hint.error {
color: var(--mx-error);
}
.hint-text {
margin-top: 8px;
font-size: 12px;
color: var(--mx-t2);
line-height: 1.7;
}
.hint-text.invalid {
color: var(--mx-error);
}
.notice {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
}
.notice.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
@media (max-width: 1023px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,69 @@
import type { CurrentUser } from '@/shared'
/**
* 登录有效期(系统级设置)的纯函数:范围校验与到期时间显示。
*
* 抽成不依赖 Vue 的模块,是为了能在 node --test 下直接测到边界(0、721、小数、空串),
* 而不必挂载组件;面板与用户菜单共用同一份格式化逻辑,避免两处显示不一致。
*/
/** 服务端 /api/system/session 未就绪时的兜底范围,与 auth.MinSessionHours / MaxSessionHours 一致。 */
export const SESSION_HOURS_FALLBACK = { minHours: 1, maxHours: 720, defaultHours: 24 } as const
export interface SessionHoursBounds {
minHours: number
maxHours: number
}
export type SessionHoursCheck = { ok: true; hours: number } | { ok: false; message: string }
/** 校验面板输入的登录有效期(整数小时)。服务端仍会独立校验一遍,这里只是为了当场给出中文提示。 */
export function validateSessionHours(raw: string | number, bounds: SessionHoursBounds): SessionHoursCheck {
const text = typeof raw === 'number' ? String(raw) : raw.trim()
if (text === '') return { ok: false, message: '请填写登录有效期(小时)' }
if (!/^\d+$/.test(text)) return { ok: false, message: '登录有效期只能是整数小时' }
const hours = Number(text)
if (hours < bounds.minHours || hours > bounds.maxHours) {
return { ok: false, message: `登录有效期需在 ${bounds.minHours}~${bounds.maxHours} 小时之间` }
}
return { ok: true, hours }
}
/** 把小时数说成人话,现场一眼能看出「这是登录后能待多久」。 */
export function describeSessionLifetime(hours: number): string {
if (!Number.isFinite(hours) || hours <= 0) return '登录有效期未设置'
const days = hours / 24
const dayText = Number.isInteger(days) ? `${days} 天` : `${days.toFixed(1)} 天`
return `登录后最长可保持 ${hours} 小时(${dayText})不用重新登录;期间只要继续操作,到期时间会自动顺延。`
}
/**
* 从 /auth/me 的响应里取当前会话的到期时间。
*
* CurrentUser 类型定义在 shared/types(本轮不改共享包),字段是附加下发的,
* 所以这里按可选字段读,值不合法时一律当「没有」处理。
*/
export function readSessionExpiresAt(
user: (CurrentUser & { sessionExpiresAt?: unknown }) | null | undefined,
): string | null {
const raw = user?.sessionExpiresAt
if (typeof raw !== 'string' || raw.trim() === '') return null
return Number.isNaN(new Date(raw).getTime()) ? null : raw
}
function pad(value: number): string {
return String(value).padStart(2, '0')
}
/**
* 把服务端下发的 RFC3339(UTC)到期时间渲染成本机时区的「当前会话将于 2026-09-12 08:31 过期」。
* 服务端只给带时区的时间串,换算成本机时间由前端做——现场看的是自己电脑上的钟。
*/
export function formatSessionExpiry(iso: string | null | undefined): string {
if (iso === null || iso === undefined || iso === '') return '当前会话的到期时间未知'
const at = new Date(iso)
if (Number.isNaN(at.getTime())) return '当前会话的到期时间未知'
const text = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ` +
`${pad(at.getHours())}:${pad(at.getMinutes())}`
return `当前会话将于 ${text} 过期`
}
@@ -879,6 +879,18 @@ export const rulesApi = {
export type EventSettingsPatch = Partial<EventSettings>
/**
* 分批结算开关的取值。
*
* shared/types 的 EventSettings 还没收敛这个字段(改共享类型会影响大屏),
* 所以在管理端按可选字段读取:旧服务端 / 示例数据不带它时按「实时更新」处理,
* 与 Go 侧 ParseEventSettings 的默认值一致。
*/
export function liveOnPublishOf(settings: EventSettings | null | undefined): boolean {
const value = (settings?.standings as { liveOnPublish?: unknown } | undefined)?.liveOnPublish
return typeof value === 'boolean' ? value : true
}
export const settingsApi = {
async save(patch: EventSettingsPatch): Promise<MockAware<EventSettings>> {
return withMockInfo(
@@ -894,6 +906,104 @@ export const settingsApi = {
},
}
// ---------------------------------------------------------------------------
// 分批结算(GET /api/results/settlement、POST /api/results/settle)
// ---------------------------------------------------------------------------
/** 未结算的 (小项, 组别):延迟结算下已发布成绩还没进榜单的那一组。 */
export interface SettlementGroup {
eventId: number
categoryId: number
categoryName: string
/** 该出成绩的报名项数(弃权 / 失格不计入)。 */
required: number
/** 其中已经有可发布成绩的条数。 */
published: number
}
export interface SettlementStatus {
/** false = 延迟结算:发布只落成绩,等整组录完才更新榜单。 */
liveOnPublish: boolean
groups: SettlementGroup[]
}
function readSettlementStatus(raw: unknown): SettlementStatus {
if (!isRecord(raw)) throw new ApiError('PARSE_FAILED', '结算状态格式不符', 0, raw)
const groups = Array.isArray(raw.groups) ? raw.groups : []
return {
liveOnPublish: raw.liveOnPublish !== false,
groups: groups.filter(isRecord).map((item) => ({
eventId: readNumber(item, 'eventId') ?? 0,
categoryId: readNumber(item, 'categoryId') ?? 0,
categoryName: readString(item, 'categoryName') ?? '',
required: readNumber(item, 'required') ?? 0,
published: readNumber(item, 'published') ?? 0,
})),
}
}
export const settlementApi = {
/** 查未结算清单;这是提示用的辅助信息,读失败由调用方决定是否忽略。 */
async status(eventId: number, categoryId?: number | null): Promise<SettlementStatus> {
const body = await requestJson<unknown>('/results/settlement', {
query: { eventId, categoryId: categoryId ?? undefined },
})
return readSettlementStatus(body)
},
/** 立即结算:按当前已发布成绩重算积分流水并刷新榜单(写操作不降级)。 */
async settle(eventId: number, categoryId?: number | null): Promise<void> {
await requestJson<unknown>('/results/settle', {
method: 'POST',
json: { eventId, categoryId: categoryId ?? null },
})
},
}
// ---------------------------------------------------------------------------
// 登录有效期(系统级设置:GET/PUT /api/system/session)
// ---------------------------------------------------------------------------
export interface SessionSettings {
sessionHours: number
defaultSessionHours: number
minHours: number
maxHours: number
/** 当前会话的到期时间(RFC3339 UTC);未登录为 null。 */
expiresAt: string | null
}
/**
* 登录有效期存在系统库(system.db),不是赛事级 /api/settings:
* 换一个赛事项目,这台机器上所有人的会话寿命不该跟着变。
*
* 读写都不降级示例数据:假的「已保存」会让管理员以为改完就生效了。
*/
export const sessionSettingsApi = {
async load(): Promise<SessionSettings> {
return toSessionSettings(await requestJson<unknown>('/system/session'))
},
async save(hours: number): Promise<SessionSettings> {
return toSessionSettings(
await requestJson<unknown>('/system/session', { method: 'PUT', json: { sessionHours: hours } }),
)
},
}
function toSessionSettings(value: unknown): SessionSettings {
if (!isRecord(value)) throw new ApiError('PARSE_FAILED', '登录有效期响应格式不符', 0, value)
const sessionHours = readNumber(value, 'sessionHours')
if (sessionHours === null) throw new ApiError('PARSE_FAILED', '登录有效期响应缺少 sessionHours', 0, value)
return {
sessionHours,
defaultSessionHours: readNumber(value, 'defaultSessionHours') ?? sessionHours,
minHours: readNumber(value, 'minHours') ?? 1,
maxHours: readNumber(value, 'maxHours') ?? 720,
expiresAt: readString(value, 'expiresAt'),
}
}
// ---------------------------------------------------------------------------
// 字体(内置字体 + 上传字体)
// ---------------------------------------------------------------------------
+26 -2
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import MxIcon from '../components/MxIcon.vue'
import ToastHost from '../components/ToastHost.vue'
import type { IconName } from '../components/icons'
import { formatSessionExpiry } from '../components/system/sessionSettings'
import { theme, toggleTheme } from '../theme'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
@@ -78,6 +79,18 @@ const navGroups = computed<NavGroup[]>(() =>
const currentTitle = computed(() => (typeof route.meta.title === 'string' ? route.meta.title : '管理面板'))
const pendingCount = computed(() => data.events.filter((event) => event.status === 'pending').length)
const themeToggleLabel = computed(() => (theme.value === 'dark' ? '切换到浅色模式' : '切换到深色模式'))
/** 用户菜单里显示当前会话的到期时间,现场自己就能确认「够不够撑到比赛结束」。 */
const sessionExpiryText = computed(() =>
auth.isGuest ? '' : formatSessionExpiry(auth.sessionExpiresAt),
)
/**
* 会话心跳间隔:服务端按「有请求」滑动续期,Cookie 的期限又只在 /auth/me 里跟着同步,
* 所以页面开着不动时也要定期问一次。10 分钟远小于最小有效期(1 小时),
* 任何一次心跳都能把 Cookie 推回到期时间之后,不会出现「服务端还认、浏览器先丢 Cookie」。
*/
const SESSION_KEEPALIVE_MS = 10 * 60 * 1000
let keepalive: number | null = null
const connection = computed(() => {
if (data.isMock) return { tone: 'warning', text: '示例数据', hint: data.mockLabel ?? '' }
@@ -113,6 +126,17 @@ onMounted(async () => {
if (auth.user?.mustChangePassword && route.name !== 'password') {
void router.push({ name: 'password' })
}
// 心跳跟着布局的生命周期走;用 setInterval 而不是每个页面各挂一个,避免多份定时器。
keepalive = window.setInterval(() => {
void auth.refreshSession()
}, SESSION_KEEPALIVE_MS)
})
onUnmounted(() => {
if (keepalive !== null) {
window.clearInterval(keepalive)
keepalive = null
}
})
</script>
@@ -200,7 +224,7 @@ onMounted(async () => {
</RouterLink>
</template>
<template v-else>
<span class="who">
<span class="who" :title="sessionExpiryText">
<span class="mx-avatar">{{ auth.displayName.slice(0, 1) }}</span>
<span class="who-text">
<strong>{{ auth.displayName }}</strong>
+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()
}
+34 -2
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { CurrentUser } from '@/shared'
import { adminApi, mockMode, setUnauthorizedHandler } from '../api/client'
import { readSessionExpiresAt } from '../components/system/sessionSettings'
/**
* 会话状态。
@@ -13,6 +14,8 @@ export const useAuthStore = defineStore('admin-auth', () => {
const user = ref<CurrentUser | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
/** 当前会话的到期时间(RFC3339 UTC);由 /auth/me 下发,用户菜单与系统设置页据此显示。 */
const sessionExpiresAt = ref<string | null>(null)
/** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */
const initialized = ref(false)
@@ -32,10 +35,13 @@ export const useAuthStore = defineStore('admin-auth', () => {
loading.value = true
error.value = null
try {
user.value = await adminApi.me()
const me = await adminApi.me()
user.value = me
sessionExpiresAt.value = readSessionExpiresAt(me)
} catch {
// 未登录 / 后端未就绪都走只读访客,不把用户挡在门外。
user.value = null
sessionExpiresAt.value = null
} finally {
loading.value = false
}
@@ -45,7 +51,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
loading.value = true
error.value = null
try {
user.value = await adminApi.login(username, password)
const me = await adminApi.login(username, password)
user.value = me
sessionExpiresAt.value = readSessionExpiresAt(me)
initialized.value = true
return true
} catch (caught) {
@@ -56,6 +64,26 @@ export const useAuthStore = defineStore('admin-auth', () => {
}
}
/**
* 会话心跳:重新问一次 /auth/me。
*
* 服务端的滑动续期靠「有请求」触发,而 Cookie 的期限又只在 /auth/me 里跟着续期同步,
* 所以打开着页面不动的管理端要定期打一次。失败(断网、已登出)一律静默:
* 心跳不能把只读访客踢出界面,也不能覆盖别的请求刚拿到的身份。
*/
async function refreshSession(): Promise<void> {
if (isGuest.value) return
try {
const me = await adminApi.me()
const expiresAt = readSessionExpiresAt(me)
if (expiresAt === null) return
user.value = me
sessionExpiresAt.value = expiresAt
} catch {
// 静默:下一次心跳再试。
}
}
async function logout(): Promise<void> {
try {
await adminApi.logout()
@@ -63,10 +91,12 @@ export const useAuthStore = defineStore('admin-auth', () => {
// 登出失败也要清本地态,否则用户会卡在已登录界面。
}
user.value = null
sessionExpiresAt.value = null
}
setUnauthorizedHandler(() => {
user.value = null
sessionExpiresAt.value = null
})
return {
@@ -78,9 +108,11 @@ export const useAuthStore = defineStore('admin-auth', () => {
readOnly,
displayName,
roleLabel,
sessionExpiresAt,
mockMode,
load,
login,
logout,
refreshSession,
}
})
@@ -0,0 +1,135 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 运动员批量动作的前端契约。
*
* 这是绕开写限流的正解:逐个 DELETE 是「一人一条请求」,现场清理几十个人就会撞上配额;
* 因此这里钉住三件事——一个请求完成整批、逐条结果按顺序保留、缺失的逐条失败不能变成整批成功。
*/
interface Captured {
url?: string
method?: string
body?: string
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: { items: [] } })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
body: typeof init?.body === 'string' ? init.body : undefined,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
/** 写请求体里总会被注入 requestId(幂等回放用),比较字段时先摘掉它。 */
function requestBody(): Record<string, unknown> {
const parsed: Record<string, unknown> = JSON.parse(lastRequest.body ?? '{}')
delete parsed['requestId']
return parsed
}
const { adminApi } = await import('../api/client')
function item(id: number, ok = true, extra: Record<string, unknown> = {}): Record<string, unknown> {
return { id, ok, ...extra }
}
test('批量停用是一个请求:POST /api/athletes/batch,body 带 action 与 ids', async () => {
respondWith(() => jsonResponse({ ok: true, data: { action: 'disable', items: [item(1), item(2)] } }))
await adminApi.client.athletes.batch('disable', [1, 2])
assert.equal(lastRequest.url, '/api/athletes/batch')
assert.equal(lastRequest.method, 'POST')
assert.deepEqual(requestBody(), { action: 'disable', ids: [1, 2] })
})
test('dryRun 才显式带 dryRun: true,普通请求不带(老服务端不能被静默降级成预览)', async () => {
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge', dryRun: true, items: [item(1)] } }))
const preview = await adminApi.client.athletes.batch('purge', [1], true)
assert.deepEqual(requestBody(), { action: 'purge', ids: [1], dryRun: true })
assert.equal(preview.dryRun, true)
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge', items: [item(1)] } }))
await adminApi.client.athletes.batch('purge', [1])
assert.deepEqual(requestBody(), { action: 'purge', ids: [1] })
})
test('逐条成败与合计按 items 还原,purge 统计原样带出', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
action: 'purge',
requested: 3,
succeeded: 2,
failed: 1,
items: [item(1), item(2), item(99, false, { code: 'NOT_FOUND', message: '运动员不存在' })],
purge: {
deletedEntries: 2,
deletedResults: 2,
deletedLedger: 3,
deletedBreaks: 0,
recomputedEvents: [5],
},
},
}),
)
const result = await adminApi.client.athletes.batch('purge', [1, 2, 99])
assert.equal(result.requested, 3)
assert.equal(result.succeeded, 2)
assert.equal(result.failed, 1)
assert.deepEqual(result.items[2], { id: 99, ok: false, code: 'NOT_FOUND', message: '运动员不存在' })
assert.deepEqual(result.purge, {
deletedEntries: 2,
deletedResults: 2,
deletedLedger: 3,
deletedBreaks: 0,
recomputedEvents: [5],
})
})
test('逐条结果缺失时按失败处理:删不掉不能报成删掉了', async () => {
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge' } }))
const result = await adminApi.client.athletes.batch('purge', [1, 2])
assert.deepEqual(result.items, [])
assert.equal(result.succeeded, 0)
assert.equal(result.failed, 0)
assert.equal(result.purge, undefined)
})
test('响应里没有 ok: true 的条目按失败处理,不猜成成功', async () => {
respondWith(() =>
jsonResponse({ ok: true, data: { action: 'disable', items: [{ id: 1 }, { id: 2, ok: true }] } }),
)
const result = await adminApi.client.athletes.batch('disable', [1, 2])
assert.equal(result.succeeded, 1)
assert.equal(result.failed, 1)
assert.equal(result.items[0]?.ok, false)
assert.equal(result.items[1]?.ok, true)
})
@@ -0,0 +1,202 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiError } from '@/shared'
import {
downloadBaseDataXLSX,
downloadEntriesXLSX,
entriesExportPath,
importBaseDataXLSX,
} from '@/shared/basedata'
/**
* 报名名单导出与基础数据包导入的前端契约:路径、筛选参数、dryRun 标志、解析出的差异。
*
* 路径或参数写错时现场拿到的是 404 或「筛了却导出全量」;dryRun 标志丢了则会
* 让「预览差异」直接写库。这里用 stub fetch 钉住真正发出去的请求。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function xlsxResponse(): Response {
return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), {
status: 200,
headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
})
}
let reply: () => Response = () => xlsxResponse()
let lastRequest: Captured = {}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body }
return reply()
}) as typeof fetch
function respondWith(next: () => Response): void {
lastRequest = {}
reply = next
}
function formValue(body: unknown, key: string): FormDataEntryValue | null {
assert.ok(body instanceof FormData, '上传必须是 multipart FormData')
return body.get(key)
}
test('报名名单导出路径按筛选条件拼 query,留空的维度不出现', () => {
assert.equal(entriesExportPath(), '/export/entries.xlsx')
assert.equal(entriesExportPath({ eventId: 3 }), '/export/entries.xlsx?eventId=3')
assert.equal(
entriesExportPath({ eventId: 3, categoryId: 9, teamId: 1, heat: 2 }),
'/export/entries.xlsx?eventId=3&categoryId=9&teamId=1&heat=2',
)
})
test('导出报名名单走 GET /api/export/entries.xlsx,文件名是「报名名单.xlsx」', async () => {
respondWith(() => xlsxResponse())
const result = await downloadEntriesXLSX({ eventId: 3, heat: 2 })
assert.equal(lastRequest.url, '/api/export/entries.xlsx?eventId=3&heat=2')
assert.equal(lastRequest.method, 'GET')
assert.equal(result.filename, '报名名单.xlsx')
assert.equal(result.blob.size, 4)
})
test('导出基础数据包走 GET /api/export/basedata.xlsx,文件名是「基础数据包.xlsx」', async () => {
respondWith(() => xlsxResponse())
const result = await downloadBaseDataXLSX()
assert.equal(lastRequest.url, '/api/export/basedata.xlsx')
assert.equal(result.filename, '基础数据包.xlsx')
})
test('预览差异带 dryRun=1,结果里的三类计数与逐行差异都解析出来', async () => {
respondWith(() => jsonResponse({
ok: true,
data: {
dryRun: true,
added: 1,
updated: 2,
unchanged: 5,
errorCount: 1,
errors: ['单位 第 3 行:单位编码必填'],
changes: [
{
sheet: '单位',
line: 2,
action: 'updated',
key: 'T1',
fields: [{ field: '单位名称', from: '第一分公司', to: '第一分公司(改)' }],
},
],
},
}))
const file = new File(['x'], 'basedata.xlsx')
const result = await importBaseDataXLSX(file, { dryRun: true })
assert.equal(lastRequest.url, '/api/export/basedata/import')
assert.equal(lastRequest.method, 'POST')
assert.equal(formValue(lastRequest.body, 'dryRun'), '1')
assert.equal(formValue(lastRequest.body, 'file'), file)
assert.equal(result.dryRun, true)
assert.equal(result.added, 1)
assert.equal(result.updated, 2)
assert.equal(result.unchanged, 5)
assert.equal(result.errorCount, 1)
assert.deepEqual(result.errors, ['单位 第 3 行:单位编码必填'])
assert.equal(result.changes.length, 1)
assert.equal(result.changes[0].sheet, '单位')
assert.equal(result.changes[0].line, 2)
assert.equal(result.changes[0].fields[0].field, '单位名称')
assert.equal(result.changes[0].fields[0].from, '第一分公司')
assert.equal(result.changes[0].fields[0].to, '第一分公司(改)')
})
test('确认写入不带 dryRun,避免预览之后又走一次只读分支', async () => {
respondWith(() => jsonResponse({
ok: true,
data: { dryRun: false, added: 3, updated: 0, unchanged: 0, errorCount: 0, errors: [], changes: [] },
}))
const result = await importBaseDataXLSX(new File(['x'], 'basedata.xlsx'), { dryRun: false })
assert.equal(formValue(lastRequest.body, 'dryRun'), null)
assert.equal(result.dryRun, false)
assert.equal(result.added, 3)
})
test('服务端 400 的行级错误原样抛出,页面能拿到那句中文', async () => {
respondWith(() => jsonResponse({
ok: false,
error: { code: 'VALIDATION_FAILED', message: '导出链接参数不对' },
}, 400))
await assert.rejects(
() => downloadEntriesXLSX({ heat: 0 }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 400)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.equal(error.message, '导出链接参数不对')
return true
},
)
})
test('导入 403 / 500 原样抛出,不降级成示例数据', async () => {
respondWith(() => jsonResponse({
ok: false,
error: { code: 'FORBIDDEN', message: '只有管理员可以导入基础数据' },
}, 403))
await assert.rejects(
() => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'FORBIDDEN')
assert.equal(error.message, '只有管理员可以导入基础数据')
return true
},
)
})
test('响应不是 {ok,data} 结构时报解析错误,而不是假装导入成功', async () => {
respondWith(() => jsonResponse({ hello: 'world' }))
await assert.rejects(
() => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'INTERNAL')
return true
},
)
})
test('连不上服务端时报 NETWORK,页面据此区分「后端未就绪」', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => downloadEntriesXLSX(),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
return true
},
)
})
@@ -0,0 +1,122 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 组别「就地更新」的前端契约:方法 / 路径 / 请求体,以及响应是否按组别列表回传。
*
* 这条路径是有报名数据的小项唯一能改名的通道,路径拼错(比如漏掉 categoryId)
* 会静默走回整表替换的 409,现场只会看到「已有报名数据不能增删组别」。
*/
interface Captured {
url?: string
method?: string
body?: string
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: [] })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
body: typeof init?.body === 'string' ? init.body : undefined,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
/** 写请求体里总会被注入 requestId(幂等回放用),比较字段时先摘掉它。 */
function requestBody(): Record<string, unknown> {
const parsed: Record<string, unknown> = JSON.parse(lastRequest.body ?? '{}')
delete parsed['requestId']
return parsed
}
const { adminApi } = await import('../api/client')
const input = {
name: '男子甲组',
multiplier: 2,
gender: '男' as const,
ageMin: 18,
ageMax: 35,
sortOrder: 1,
}
test('就地更新走 PUT /api/events/{id}/categories/{categoryId},请求体是单个组别', async () => {
respondWith(() => jsonResponse({ ok: true, data: [] }))
await adminApi.client.events.updateCategory(7, 12, input)
assert.equal(lastRequest.url, '/api/events/7/categories/12')
assert.equal(lastRequest.method, 'PUT')
assert.deepEqual(requestBody(), input)
})
test('响应按组别列表回传,直接拿到更新后的名字', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: [
{ id: 12, eventId: 7, name: '男子甲组', multiplier: 2, gender: '男', ageMin: 18, ageMax: 35, sortOrder: 1 },
],
}),
)
const categories = await adminApi.client.events.updateCategory(7, 12, input)
assert.equal(categories.length, 1)
assert.equal(categories[0]?.name, '男子甲组')
assert.equal(categories[0]?.multiplier, 2)
})
test('服务端 409 的原文要能被页面拿到(提示里那句话是现场唯一的线索)', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'CONFLICT', message: '组别名称重复:男子' } }, 409),
)
await assert.rejects(
() => adminApi.client.events.updateCategory(7, 12, input),
(error: unknown) => (error as { message?: string }).message === '组别名称重复:男子',
)
})
test('服务端不可达时,组别就地更新与运动员批量都降级到示例数据而不是抛错', async () => {
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
const { mockBaseData } = await import('../components/baseData/mockBaseData')
// 断网:fetch 直接抛,客户端归成 NETWORK,baseDataApi 据此降级到示例数据。
respondWith(() => {
throw new TypeError('Failed to fetch')
})
const event = mockBaseData.listEvents()[0]
assert.ok(event !== undefined)
const category = (mockBaseData.getEvent(event.id).categories ?? [])[0]
assert.ok(category !== undefined)
const categories = await baseDataApi.events.updateCategory(event.id, category.id, {
...input,
name: '降级改名',
})
assert.equal(categories.find((item) => item.id === category.id)?.name, '降级改名')
assert.equal(baseDataFallback.value, true)
const athlete = mockBaseData.listAthletes()[0]
assert.ok(athlete !== undefined)
const result = await baseDataApi.athletes.batch('disable', [athlete.id])
assert.equal(result.succeeded, 1)
assert.equal(result.failed, 0)
})
+149
View File
@@ -0,0 +1,149 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiError } from '@/shared'
import { ENTRY_BATCH_PATH, submitEntryBatch } from '../pages/entryBatch'
/**
* 报名项批量操作的请求契约:方法 / 路径 / 请求体字段必须与服务端
* internal/httpapi/entries_batch.go 的 entryBatchPayload 一致;
* 写请求漏掉 X-CSRF-Token 会被中间件 403,现场只会看到「删不掉」。
*/
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
body?: unknown
credentials?: string
}
let lastRequest: Captured = {}
let reply: () => Response = () => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } })
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function emptySummary() {
return {
ok: 0,
failed: 0,
skipped: 0,
changed: 0,
removedEntries: 0,
removedResults: 0,
removedLedger: 0,
removedBreaks: 0,
recomputedEvents: [],
}
}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
credentials: init?.credentials,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
test('批量操作走 POST /api/entries/batch,body 带 action + ids', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
items: [{ id: 1, ok: true }],
summary: { ...emptySummary(), ok: 1, changed: 1, recomputedEvents: [3] },
},
}),
)
const result = await submitEntryBatch({ action: 'scratch', ids: [1, 1, 2] })
assert.equal(lastRequest.url, ENTRY_BATCH_PATH)
assert.equal(lastRequest.method, 'POST')
assert.equal(lastRequest.credentials, 'include')
assert.equal((lastRequest.body as { action: string }).action, 'scratch')
assert.deepEqual((lastRequest.body as { ids: number[] }).ids, [1, 1, 2])
// 写请求必须有 requestId(幂等)与 JSON 内容类型。
assert.equal(typeof (lastRequest.body as { requestId?: unknown }).requestId, 'string')
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
assert.deepEqual(result.summary.recomputedEvents, [3])
})
// 已登录写请求必须带 CSRF:漏掉就是 403,退赛 / 移出都不会生效。
test('带 csrf_token Cookie 时补上 X-CSRF-Token', async () => {
const original = (globalThis as { document?: unknown }).document
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-123; b=2' }
try {
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
await submitEntryBatch({ action: 'restore', ids: [2] })
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-123')
} finally {
if (original === undefined) delete (globalThis as { document?: unknown }).document
else (globalThis as { document?: unknown }).document = original
}
})
test('没有 document(非浏览器环境)时不带 CSRF,也不报错', async () => {
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
await submitEntryBatch({ action: 'remove', ids: [3] })
assert.equal(lastRequest.headers?.['X-CSRF-Token'], undefined)
})
// 服务端的中文原文要原样传给页面;批量里的逐条失败也在 data 里,不能被当成请求失败。
test('服务端报错时原样抛出 code 与中文 message', async () => {
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '一次最多处理 500 条报名项,请分批提交' } }, 400))
await assert.rejects(
() => submitEntryBatch({ action: 'remove', ids: [1] }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.match(error.message, /分批提交/)
return true
},
)
})
test('逐条失败(ok:false)仍算成功响应,由页面逐条提示', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
items: [
{ id: 1, ok: true },
{ id: 999999, ok: false, error: '报名项不存在' },
],
summary: { ...emptySummary(), ok: 1, failed: 1 },
},
}),
)
const result = await submitEntryBatch({ action: 'remove', ids: [1, 999999] })
assert.equal(result.summary.failed, 1)
assert.equal(result.items[1]?.error, '报名项不存在')
})
test('网络不可达时报「没有生效」,不能让操作员以为已经删掉了', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => submitEntryBatch({ action: 'remove', ids: [1] }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
assert.match(error.message, /没有生效/)
return true
},
)
})
+117
View File
@@ -0,0 +1,117 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
MAX_ENTRY_BATCH,
dedupeEntryIds,
entryBatchOutcome,
entryBatchSizeError,
entryBatchSkipNote,
planEntryBatch,
type EntryBatchRow,
} from '../pages/entryBatch'
function row(overrides: Partial<EntryBatchRow> & { entryId: number }): EntryBatchRow {
return {
name: '张三',
bib: '1042',
team: '第一分公司',
categoryId: 7,
entryStatus: 'registered',
...overrides,
}
}
test('dedupeEntryIds:去重、丢非法 id,保持勾选顺序', () => {
assert.deepEqual(dedupeEntryIds([3, 1, 3, 0, -2, 1, 2.5, 2]), [3, 1, 2])
assert.deepEqual(dedupeEntryIds([]), [])
})
test('entryBatchSizeError:空选与超上限都给出中文提示', () => {
assert.equal(entryBatchSizeError(0), '请先勾选要操作的报名项')
assert.equal(entryBatchSizeError(3), null)
assert.equal(entryBatchSizeError(MAX_ENTRY_BATCH), null)
assert.match(entryBatchSizeError(MAX_ENTRY_BATCH + 1) ?? '', /分批提交/)
})
// dq / dns 是别人的判定:退赛不能覆盖,跳过时必须说清原因。
test('entryBatchSkipNote:只动 registered / scratched,其余跳过并说明', () => {
assert.equal(entryBatchSkipNote('scratch', 'registered'), null)
assert.equal(entryBatchSkipNote('scratch', 'scratched'), '已是退赛状态,跳过')
assert.equal(entryBatchSkipNote('scratch', 'dq'), '已被判罚取消资格(dq),跳过,不覆盖原判定')
assert.equal(entryBatchSkipNote('scratch', 'dns'), '已标记未出场(dns),跳过,不覆盖原判定')
assert.equal(entryBatchSkipNote('restore', 'scratched'), null)
assert.equal(entryBatchSkipNote('restore', 'registered'), '当前正常参赛,无需恢复')
// 移出报名不看状态:报名项本身就是要被物理删除的。
assert.equal(entryBatchSkipNote('remove', 'dq'), null)
})
test('planEntryBatch:退赛只改 registered,跳过的人在确认框里列出来', () => {
const plan = planEntryBatch('scratch', [
row({ entryId: 1 }),
row({ entryId: 2, entryStatus: 'dq', name: '李四' }),
row({ entryId: 1 }),
])
// 请求仍带上全部勾选 id:跳过与否由服务端逐条回报,前端不替它过滤。
assert.deepEqual(plan.ids, [1, 2])
assert.equal(plan.changed, 1)
assert.equal(plan.scopeCount, 1)
assert.deepEqual(plan.skipped, [{ name: '李四', note: '已被判罚取消资格(dq),跳过,不覆盖原判定' }])
assert.ok(plan.details.some((line) => line.includes('李四')))
assert.match(plan.message, /0 分且不占名次/)
})
test('planEntryBatch:恢复只改已退赛的人', () => {
const plan = planEntryBatch('restore', [
row({ entryId: 1, entryStatus: 'scratched' }),
row({ entryId: 2, entryStatus: 'registered', name: '王五' }),
])
assert.deepEqual(plan.ids, [1, 2])
assert.equal(plan.changed, 1)
assert.deepEqual(plan.skipped, [{ name: '王五', note: '当前正常参赛,无需恢复' }])
})
// 移出报名必须让操作员看到「删谁」与「重算几个组别」,规模大时还要明说别人的名次会变。
test('planEntryBatch:移出报名列出被删的人与重算范围', () => {
const plan = planEntryBatch('remove', [
row({ entryId: 1, name: '张三', bib: '1042', team: '第一分公司' }),
row({ entryId: 2, name: '李四', bib: null, team: '第二分公司', categoryId: 8 }),
])
assert.deepEqual(plan.ids, [1, 2])
assert.equal(plan.changed, 2)
assert.equal(plan.scopeCount, 2)
assert.deepEqual(plan.skipped, [])
assert.ok(plan.details.some((line) => line.includes('张三|号码 1042|第一分公司')))
assert.ok(plan.details.some((line) => line.includes('李四|号码 —|第二分公司')))
assert.ok(plan.details.some((line) => line.includes('会重算 1 个小项(当前项目)、2 个组别')))
assert.match(plan.message, /不可恢复/)
})
test('planEntryBatch:超过 20 人时只列前 20 并追加「别人的名次会变」', () => {
const rows = Array.from({ length: 25 }, (_, index) => row({ entryId: index + 1, name: `选手${index + 1}` }))
const plan = planEntryBatch('remove', rows)
assert.equal(plan.changed, 25)
assert.ok(plan.details.some((line) => line.includes('还有 5 人未列出')))
assert.match(plan.message, /别人的名次会变/)
})
test('entryBatchOutcome:汇总逐条成败与删除规模', () => {
const text = entryBatchOutcome({
items: [],
summary: {
ok: 2,
failed: 1,
skipped: 1,
changed: 1,
removedEntries: 1,
removedResults: 1,
removedLedger: 2,
removedBreaks: 0,
recomputedEvents: [3],
},
})
assert.match(text, /成功 2 条(跳过 1 条)/)
assert.match(text, /失败 1 条/)
assert.match(text, /删除报名 1 条/)
assert.match(text, /删除流水 2 条/)
assert.match(text, /重算 1 个小项/)
})
+122
View File
@@ -0,0 +1,122 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { entryProgress, progressScopeLabel, type ProgressRow } from '../pages/entryProgress'
import { judgeEntryRow } from '../pages/entryGuards'
function row(heat: number | null, resultText: string, over: Partial<ProgressRow> = {}): ProgressRow {
return { heat, resultText, rank: null, status: 'ok', ...over }
}
test('分母是当前筛选范围:选中某一批时只数这一批', () => {
const rows = [row(1, '66'), row(1, ''), row(2, '70.1'), row(2, '71.2')]
const heat2 = entryProgress({ rows, filter: 2, unit: 's', needsRank: false })
assert.equal(heat2.total, 2)
assert.equal(heat2.filled, 2)
assert.equal(heat2.done, true)
assert.equal(heat2.text, '第 2 批 已录完 2/2')
assert.equal(heat2.percent, 100)
const heat1 = entryProgress({ rows, filter: 1, unit: 's', needsRank: false })
assert.equal(heat1.total, 2)
assert.equal(heat1.filled, 1)
assert.equal(heat1.missing, 1)
assert.equal(heat1.text, '第 1 批 已录 1/2')
const all = entryProgress({ rows, filter: 'all', unit: 's', needsRank: false })
assert.equal(all.total, 4)
assert.equal(all.filled, 3)
assert.equal(all.text, '全部 已录 3/4')
assert.equal(all.percent, 75)
assert.equal(all.done, false)
})
test('筛选「未分批」只数没有批次号的行', () => {
const rows = [row(null, '66'), row(null, ''), row(1, '70')]
const progress = entryProgress({ rows, filter: 'none', unit: 's', needsRank: false })
assert.equal(progress.total, 2)
assert.equal(progress.filled, 1)
assert.equal(progress.text, '未分批 已录 1/2')
})
// 关键约束:进度条的「已录」必须与发布防呆 / 行徽标完全同源,否则会出现
// 「进度条说录完了、发布说缺 8 条」。这里直接拿 judgeEntryRow 的结果对账。
test('已录判据与 entryGuards 的 judgeEntryRow 同源(逐行对账)', () => {
const rows = [
row(1, '66.33'),
row(1, ''),
row(1, '1:70'),
row(1, '', { status: 'dns' }),
row(1, '0'),
row(1, '', { rank: 1 }),
row(1, ' '),
]
for (const needsRank of [false, true]) {
const expected = rows.filter((item, index) => {
const judged = judgeEntryRow({
label: `第 ${index + 1} 行`,
resultText: item.resultText,
unit: 's',
status: item.status,
rank: item.rank,
needsRank,
})
return judged.state === 'filled'
}).length
const progress = entryProgress({ rows, filter: 'all', unit: 's', needsRank })
assert.equal(progress.filled, expected, `needsRank=${needsRank}`)
}
// DNS 这类现场结果没有数值也是「已录」,手动名次项目只填名次也算「已录」。
const progress = entryProgress({ rows: [row(1, '', { status: 'dns' })], filter: 'all', unit: 's', needsRank: false })
assert.equal(progress.filled, 1)
const rankOnly = entryProgress({ rows: [row(1, '', { rank: 1 })], filter: 'all', unit: 's', needsRank: true })
assert.equal(rankOnly.filled, 1)
})
test('有错的行不计入已录,并在文案里报数', () => {
const rows = [
row(2, '70.1'),
row(2, '70.2'),
row(2, '70.3'),
row(2, '70.4'),
row(2, '70.5'),
row(2, '1:70'),
row(2, '约1分06秒'),
row(2, '快点跑'),
]
const progress = entryProgress({ rows, filter: 2, unit: 's', needsRank: false })
assert.equal(progress.total, 8)
assert.equal(progress.filled, 5)
assert.equal(progress.error, 3)
assert.equal(progress.missing, 0)
assert.equal(progress.done, false, '有错时永远不是完成态')
assert.equal(progress.text, '第 2 批 已录 5/8 · 3 条有错')
assert.equal(progress.percent, 63)
})
test('全部录完是完成态:文案改成已录完、进度条满格、变绿由 done 驱动', () => {
const rows = [row(3, '70.1'), row(3, '', { status: 'dq' }), row(3, '1:06')]
const progress = entryProgress({ rows, filter: 3, unit: 's', needsRank: false })
assert.equal(progress.done, true)
assert.equal(progress.filled, 3)
assert.ok(progress.text.includes('已录完'), progress.text)
assert.equal(progress.percent, 100)
const all = entryProgress({ rows, filter: 'all', unit: 's', needsRank: false })
assert.equal(all.text, '全部 已录完 3/3')
})
test('筛选范围内没有行时不算完成,也不会出现 NaN 百分比', () => {
const progress = entryProgress({ rows: [row(1, '66')], filter: 'none', unit: 's', needsRank: false })
assert.equal(progress.total, 0)
assert.equal(progress.percent, 0)
assert.equal(progress.done, false)
assert.equal(progress.text, '未分批 已录 0/0')
})
test('作用域称谓:全部 / 第 N 批 / 未分批', () => {
assert.equal(progressScopeLabel('all'), '全部')
assert.equal(progressScopeLabel(2), '第 2 批')
assert.equal(progressScopeLabel('none'), '未分批')
})
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { athleteCellName, inlineBibLabel } from '../pages/entryRowDisplay'
// 姓名后重复一次号码布:录入时不用在「号码」列与姓名列之间来回跳。
test('有号码布:原样展示(去掉首尾空白)', () => {
assert.equal(inlineBibLabel('1042'), '1042')
assert.equal(inlineBibLabel(' 1042 '), '1042')
assert.equal(inlineBibLabel('A-07'), 'A-07')
})
// 号码布为空时模板必须不渲染那个 span,否则这一列会被一段空白推歪。
test('没有号码布(null / 空串 / 只有空白):返回空串,不占位', () => {
assert.equal(inlineBibLabel(null), '')
assert.equal(inlineBibLabel(undefined), '')
assert.equal(inlineBibLabel(''), '')
assert.equal(inlineBibLabel(' '), '')
})
test('姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时占位', () => {
assert.equal(athleteCellName({ athleteName: '张三', memberNames: null }), '张三')
assert.equal(athleteCellName({ athleteName: '', memberNames: ['李四', '王五'] }), '李四、王五')
assert.equal(athleteCellName({ athleteName: '', memberNames: null }), '—')
assert.equal(athleteCellName({ athleteName: '', memberNames: [] }), '—')
})
// 模板契约:号码布必须紧跟在姓名后面、且带 v-if;团体行(athleteName 为空)也要显示。
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
test('录入页姓名单元格紧随号码布,且用 v-if 保证空号码不占位', () => {
const cell = ENTRY_SOURCE.match(/<td>\{\{ athleteCellName\(row\) \}\}([\s\S]*?)<\/td>/)?.[1]
assert.ok(cell !== undefined, '找不到姓名单元格')
assert.match(cell, /<span v-if="inlineBibLabel\(row\.bib\)"/, '号码布必须带 v-if,空值不渲染')
assert.match(cell, /class="bib-inline mono"/, '号码布要小号、弱化、等宽')
})
test('原来的「号码」列保留,没有被这次改动删掉', () => {
assert.match(ENTRY_SOURCE, /<td class="mono">\{\{ row\.bib \?\? '—' \}\}<\/td>/)
})
@@ -0,0 +1,61 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { EVENT_ROW_DBLCLICK_TITLE, openEventCategories } from '../components/baseData/eventRowActions'
import type { BaseDataTabKey } from '../components/baseData/tabs'
// 双击小项条目 → 跳到该小项的组别列表。
// 浏览器在 dblclick 前必然先派发两次 click,这里模拟「先点过别的行、再双击目标行」,
// 确认最终选中项是被双击的那一项,而不是上一次单击留下的那一项。
test('双击:最终选中被双击的那一项,并跳到 categories', () => {
const calls: string[] = []
let selected: number | null = null
const deps = {
selectEvent: (id: number) => {
selected = id
calls.push(`select:${id}`)
},
goTab: (tab: BaseDataTabKey) => {
calls.push(`goTab:${tab}`)
},
}
deps.selectEvent(1)
openEventCategories(7, deps)
assert.equal(selected, 7)
assert.deepEqual(calls.slice(-2), ['select:7', 'goTab:categories'])
})
// 必须先选中再跳分区:反过来会在分区切换(可能重建列表)之后再改选中,跳过去的小项就不对。
test('双击:先选中、后跳转,顺序不能反', () => {
const calls: string[] = []
openEventCategories(3, {
selectEvent: (id) => calls.push(`select:${id}`),
goTab: (tab) => calls.push(`goTab:${tab}`),
})
assert.deepEqual(calls, ['select:3', 'goTab:categories'])
})
// 模板契约:条目要绑 @dblclick,且 title 写明可发现性提示;
// 行内的小按钮(编辑 / 删除)用 @dblclick.stop 挡住,双击按钮不会误跳转。
const SECTION_SOURCE = readFileSync(
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
'utf8',
)
test('小项条目绑定双击跳转与 title 提示', () => {
assert.match(SECTION_SOURCE, /@dblclick="openCategories\(event\.id\)"/)
assert.match(SECTION_SOURCE, /:title="EVENT_ROW_DBLCLICK_TITLE"/)
assert.equal(EVENT_ROW_DBLCLICK_TITLE, '双击查看该小项的组别')
})
test('条目里的小按钮不会被双击误触发跳转', () => {
assert.match(SECTION_SOURCE, /<div class="bd-actions" @dblclick\.stop>/)
})
// 单击仍然只做选中:不能为了双击而给单击加延时(会让人以为界面卡了)。
test('单击没有被加延时,仍是直接选中', () => {
assert.match(SECTION_SOURCE, /@click="data\.selectEvent\(event\.id\)"/)
assert.ok(!/setTimeout/.test(SECTION_SOURCE), '双击实现不应引入延时')
})
+113 -50
View File
@@ -5,6 +5,7 @@ import { createApiClient } from '@/shared'
import {
REQUIRED_COLUMNS,
conflictDiffs,
decisionKeyOf,
decisionsPayload,
pruneDecisions,
recordDecisions,
@@ -119,6 +120,29 @@ test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
})
})
test('决策键号码布优先:共用一个证件号的两个人不再共用一行决策', () => {
const shared = '110101199001010001'
// 证件号不参与匹配后,同一份表里两个不同的人可以共用一个证件号;键回到号码布才不会串。
const first = conflict({ row: 2, idCard: shared, bib: '101' })
const second = conflict({ row: 3, idCard: shared, bib: '102' })
assert.equal(decisionKeyOf(first), '101')
assert.equal(decisionKeyOf(second), '102')
// 勾其中一行时另一行必须还是未决策(按证件号作键时两条会互相覆盖)。
const decisions = recordDecisions({}, [first, second], [decisionKeyOf(first)], 'keep-new')
assert.deepEqual(decisions, { 101: 'keep-new' })
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 1, undecided: 1 })
assert.deepEqual(decisionsPayload(decisions, [first, second]), [
{ idCard: shared, bib: '101', action: 'keep-new' },
])
})
test('决策键回落:没有号码布用证件号,两样都没有用姓名 + 单位', () => {
const idCard = '110101199001010002'
assert.equal(decisionKeyOf(conflict({ idCard, bib: '' })), idCard)
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '' })), '张三·F2', '两样都没有时仍要有一行唯一的键')
})
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
const mock = new MockBaseData()
const before = mock.listAthletes().length
@@ -137,57 +161,87 @@ test('导入:新建人员带证件号,姓名 + 单位命中且证件号为
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
})
test('导入:有姓名的行缺证件号 / 证件号重复都按行报错', async () => {
test('导入:有姓名的行缺证件号 / 号码布都按行报错;重复证件号不再拦截', async () => {
const mock = new MockBaseData()
const missing = await mock.importEntries(csv([row('无证人员', '')]))
assert.equal(missing.skipped, 1)
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
// 同一份表里两行共用证件号不再报错:证件号只是可选档案信息,人员按号码布 / 姓名 + 单位认,
// 两行都落在同一个人身上(第一行按号码布命中并回填证件号,第二行按姓名 + 单位命中)。
const before = mock.listAthletes().length
const duplicated = await mock.importEntries(
csv([row('重号甲', '110101199001010003'), row('重号乙', '110101199001010003', 'F2')]),
csv([row('刘洋', '110101199001010003', 'F1', '男', '201'), row('刘洋', '110101199001010003', 'F1', '男')]),
)
assert.equal(duplicated.created, 1, '第一行仍导入')
assert.equal(duplicated.skipped, 1)
assert.match(duplicated.errors[0] ?? '', /重复出现/)
assert.deepEqual(duplicated.errors, [], '重复证件号不再拦截')
assert.equal(duplicated.created, 2, '两行都落库')
assert.equal(mock.listAthletes().length, before, '两行都命中已有人员,不新增档案')
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010003')
})
test('导入:同一证件号但单位不同记为冲突且不落库,决策后按所选一侧处理', async () => {
test('导入:证件号撞别人按行报错,复用同一个人时不算差异', async () => {
const idCard = '110101199001010004'
const mock = new MockBaseData()
await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
// 库里没有「张三」:第一行按新人员建,证件号写进档案。
const first = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
assert.equal(first.created, 1)
assert.deepEqual(first.conflicts, [])
assert.ok(mock.listAthletes().some((athlete) => athlete.idCard === idCard))
const conflicted = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
// 同名同单位再来一行:姓名 + 单位命中同一个人,证件号相同不是差异。
const again = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
assert.equal(again.created, 1)
assert.deepEqual(again.conflicts, [])
// 同名但单位不同:姓名 + 单位找不到人,只能新建,而证件号已被 F1 的张三占用 → 行级报错。
const taken = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
assert.equal(taken.created, 0)
assert.equal(taken.skipped, 1)
assert.deepEqual(taken.conflicts, [])
assert.equal(
taken.errors[0],
`第 2 行:证件号 ${idCard} 已被另一个人占用(姓名 张三,号码布 未填)`,
'与服务端 idCardTakenError 同一句中文文案',
)
assert.equal(mock.listAthletes().filter((athlete) => athlete.name === '张三').length, 1)
})
test('导入:号码布命中同一人但单位不同记为冲突,决策后按所选一侧处理', async () => {
const mock = new MockBaseData()
// 种子数据里 JG 的「张伟」号码布为 101;这一行按号码布命中他,但单位写成 F2。
const conflicted = await mock.importEntries(csv([row('张伟', '', 'F2', '男', '101')]))
assert.equal(conflicted.created, 0, '冲突行不建报名')
assert.equal(conflicted.conflicts.length, 1)
assert.deepEqual(conflicted.conflicts[0], {
row: 2,
idCard,
bib: '',
athleteId,
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: null },
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F2', employeeNo: null, bib: null },
changedFields: ['teamCode'],
})
const [conflict] = conflicted.conflicts
assert.equal(conflict?.idCard, '')
assert.equal(conflict?.bib, '101')
assert.deepEqual(conflict?.changedFields, ['teamCode'])
// 未决策时再次导入,冲突仍然可见,不会静默丢弃。
const again = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
const again = await mock.importEntries(csv([row('张伟', '', 'F2', '男', '101')]))
assert.equal(again.conflicts.length, 1)
const keptExisting = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
{ idCard, bib: '', action: 'keep-existing' },
// 证件号为空时决策键就是号码布(与服务端 decisionKey() 同序)。
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', '', 'F2', '男', '101')]), [
{ idCard: '', bib: '101', action: 'keep-existing' },
])
assert.equal(keptExisting.conflicts.length, 0)
assert.equal(keptExisting.created, 1)
const afterExisting = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
assert.equal(afterExisting?.teamId, 2, '保留已有数据时人员单位不变(F1 的 id 为 2)')
assert.equal(
mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId,
1,
'保留已有数据时人员单位不变(JG 的 id 为 1)',
)
const keptNew = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
{ idCard, bib: '', action: 'keep-new' },
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', '', 'F2', '男', '101')]), [
{ idCard: '', bib: '101', action: 'keep-new' },
])
assert.equal(keptNew.conflicts.length, 0)
const afterNew = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
assert.equal(afterNew?.teamId, 3, '使用新数据时人员单位更新为 F2 的 id')
assert.equal(
mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId,
3,
'使用新数据时人员单位更新为 F2 的 id',
)
})
test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async () => {
@@ -256,34 +310,43 @@ test('导入:姓名 + 单位命中且证件号为空时回填证件号并保
assert.equal(after?.bib, '202', '表里没给号码布时保留已有值')
})
test('导入:号码布与库里不一致记为冲突,差异含 bib,决策后按所选一侧处理', async () => {
const idCard = '110101199001010007'
test('导入:号码布不同 → 按「同名的另一个人」新建并报告', async () => {
const mock = new MockBaseData()
// JG 的「张伟」证件号为空、号码布为 101,先用姓名 + 单位回填证件号。
await mock.importEntries(csv([row('张伟', idCard, 'JG', '男')]))
const athleteId = mock.listAthletes().find((item) => item.idCard === idCard)?.id
assert.ok(athleteId !== undefined)
// 种子数据里 JG 的「张伟」号码布为 101;这一行给了别的号码布 → 同名的另一个人。
const before = mock.listAthletes().length
const result = await mock.importEntries(csv([row('张伟', '110101199001010007', 'JG', '男', '999')]))
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '999')]))
assert.equal(conflicted.created, 0, '冲突行不建报名')
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
assert.deepEqual(result.conflicts, [], '号码布不同不是冲突')
assert.deepEqual(result.sameNameNewPersons, [
{ name: '张伟', teamCode: 'JG', newBib: '999', existingBib: '101' },
])
assert.equal(mock.listAthletes().length, before + 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.idCard, '110101199001010007')
// 库里那个人一个字段都没被改:同名的另一个人是新档案。
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.idCard, null)
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId, 1)
})
test('导入:同名同单位有多个人时一律进冲突面板,以第一条候选为代表', async () => {
const mock = new MockBaseData()
const firstId = mock.listAthletes().find((athlete) => athlete.bib === '101')?.id
// 先造出第二个「张伟」(都在 JG):号码布不同 → 同名的另一个人。
await mock.importEntries(csv([row('张伟', '', 'JG', '男', '999')]))
// 第三行只给了号码布 777,两个候选都对不上号码布:认不出是哪一个人,不落库。
const conflicted = await mock.importEntries(csv([row('张伟', '', 'JG', '男', '777')]))
assert.equal(conflicted.created, 0)
assert.equal(conflicted.conflicts.length, 1)
const [conflict] = conflicted.conflicts
assert.equal(conflict?.idCard, idCard)
assert.equal(conflict?.existing.bib, '101')
assert.equal(conflict?.incoming.bib, '999')
assert.deepEqual(conflict?.changedFields, ['bib'])
assert.equal(conflicted.conflicts[0]?.athleteId, firstId, '以第一条候选为代表')
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
{ idCard, bib: '999', action: 'keep-existing' },
const decided = await mock.resolveImportConflicts(csv([row('张伟', '', 'JG', '男', '777')]), [
{ idCard: '', bib: '777', action: 'keep-new' },
])
assert.equal(keptExisting.created, 1)
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '101')
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
{ idCard, bib: '999', action: 'keep-new' },
])
assert.equal(keptNew.created, 1)
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '999')
assert.equal(decided.created, 1)
assert.equal(decided.conflicts.length, 0)
assert.equal(mock.listAthletes().find((athlete) => athlete.id === firstId)?.bib, '777')
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.bib, '999', '另一个候选不被动')
})
test('导入:同一文件里号码布重复按行报错', async () => {
@@ -0,0 +1,169 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { MockBaseData } from '../components/baseData/mockBaseData'
/**
* 示例数据模式(服务端连不上)下,组别就地更新与运动员批量的降级行为。
*
* 这一层不接上的话,页面上这两个按钮在示例数据里会直接抛「无法连接服务端」——
* 而示例数据存在的意义就是让每个分区都能走通完整交互。
*/
test('示例数据的组别就地更新保留组别 id,不重建', () => {
const mock = new MockBaseData()
const event = mock.listEvents()[0]
assert.ok(event !== undefined)
const before = mock.getEvent(event.id).categories ?? []
const target = before[0]
assert.ok(target !== undefined)
const after = mock.updateCategory(event.id, target.id, {
name: '男子甲组',
multiplier: 2,
gender: '男',
ageMin: 18,
ageMax: 35,
sortOrder: target.sortOrder,
})
const updated = after.find((category) => category.id === target.id)
assert.ok(updated !== undefined)
assert.equal(updated.name, '男子甲组')
assert.equal(updated.multiplier, 2)
// 其余组别一条不少:就地更新不是整表替换。
assert.equal(after.length, before.length)
})
test('示例数据的组别就地更新:重名与不存在给与服务端一致的文案', () => {
const mock = new MockBaseData()
// 用新建的小项拿两个组别:种子事件各自只有一个组别,没法构造重名。
const seed = mock.listEvents()[0]
assert.ok(seed !== undefined)
const fresh = mock.createEvent({
sportId: seed.sportId,
code: 'DUP_CHECK',
name: '重名检查',
eventType: seed.eventType,
resultMode: seed.resultMode,
resultType: seed.resultType,
compare: seed.compare,
recordEligible: seed.recordEligible,
scored: seed.scored,
multiplier: seed.multiplier,
status: seed.status,
scheduledAt: null,
venue: null,
sortOrder: 99,
remark: null,
avgMode: seed.avgMode,
})
const categories = mock.saveCategories(fresh.id, [
{ name: '男子', multiplier: 1, gender: '男', ageMin: null, ageMax: null, sortOrder: 1 },
{ name: '女子', multiplier: 1, gender: '女', ageMin: null, ageMax: null, sortOrder: 2 },
])
const [first, second] = categories
assert.ok(first !== undefined && second !== undefined)
assert.throws(
() =>
mock.updateCategory(fresh.id, second.id, {
name: first.name,
multiplier: 1,
gender: null,
ageMin: null,
ageMax: null,
sortOrder: second.sortOrder,
}),
/组别名称重复/,
)
assert.throws(
() =>
mock.updateCategory(fresh.id, 999999, {
name: '男子',
multiplier: 1,
gender: null,
ageMin: null,
ageMax: null,
sortOrder: 1,
}),
/组别不存在/,
)
})
test('示例数据有报名数据时仍拒绝增删组别,但文案说清是「不能增删」', () => {
const mock = new MockBaseData()
const catsOf = (eventId: number) => mock.getEvent(eventId).categories ?? []
// 找一个小项:它在示例数据里带报名条数(增删会被拒),改名仍然放行。
const blocked = mock.listEvents().filter((event) => {
const categories = catsOf(event.id)
if (categories.length === 0) return false
try {
mock.saveCategories(event.id, categories.map((category) => ({ ...category })))
return false
} catch (error) {
assert.match(String(error), /不能新增或删除组别/)
return true
}
})
assert.ok(blocked.length > 0, '示例数据里应有带报名条数的小项')
const event = blocked[0]
assert.ok(event !== undefined)
const first = catsOf(event.id)[0]
assert.ok(first !== undefined)
const after = mock.updateCategory(event.id, first.id, {
name: '改个名字',
multiplier: 1,
gender: first.gender,
ageMin: null,
ageMax: null,
sortOrder: first.sortOrder,
})
assert.equal(after.find((category) => category.id === first.id)?.name, '改个名字')
})
test('示例数据批量停用:逐条回报,缺失 id 不整批失败,数据真被停用', () => {
const mock = new MockBaseData()
const athletes = mock.listAthletes()
const [a, b] = athletes
assert.ok(a !== undefined && b !== undefined)
const result = mock.batchAthletes('disable', [a.id, b.id, 999999])
assert.equal(result.action, 'disable')
assert.equal(result.requested, 3)
assert.equal(result.succeeded, 2)
assert.equal(result.failed, 1)
assert.deepEqual(result.items[2], { id: 999999, ok: false, code: 'NOT_FOUND', message: '运动员不存在' })
assert.equal(result.purge, undefined)
const after = mock.listAthletes()
assert.equal(after.find((athlete) => athlete.id === a.id)?.active, false)
assert.equal(after.length, athletes.length)
})
test('示例数据批量彻底删除:dryRun 只回报不改数据,真删才把人移出名单', () => {
const mock = new MockBaseData()
const athletes = mock.listAthletes()
const target = athletes[0]
assert.ok(target !== undefined)
const preview = mock.batchAthletes('purge', [target.id], true)
assert.equal(preview.dryRun, true)
assert.equal(preview.succeeded, 1)
assert.deepEqual(preview.purge, {
deletedEntries: 0,
deletedResults: 0,
deletedLedger: 0,
deletedBreaks: 0,
recomputedEvents: [],
})
assert.equal(mock.listAthletes().length, athletes.length)
const real = mock.batchAthletes('purge', [target.id])
assert.equal(real.succeeded, 1)
assert.equal(mock.listAthletes().length, athletes.length - 1)
assert.equal(mock.listAthletes().some((athlete) => athlete.id === target.id), false)
})
@@ -0,0 +1,167 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createPinia, setActivePinia } from 'pinia'
/**
* 登录有效期(系统级设置)的契约:
* 前端校验必须与服务端一致(1~720 整数小时),到期时间按本机时区渲染,
* 会话心跳要真的打到 GET /api/auth/me(服务端的滑动续期与 Cookie 同步都靠它)。
*
* adminApi 的 http 实例在模块初始化时就把 globalThis.fetch 绑死了,所以这里在文件顶部
* 装一个按 URL 分发的假 fetch,各用例只改 reply;用例内部再动态 import 被测模块。
*/
interface Captured {
url: string
method: string
body: Record<string, unknown> | null
}
const captured: Captured = { url: '', method: '', body: null }
let reply: (url: string, method: string) => unknown = () => ({ ok: true, data: null })
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = String(input)
const method = init?.method ?? 'GET'
captured.url = url
captured.method = method
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
const payload = reply(url, method)
if (payload instanceof Response) return payload
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
/** systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。 */
;(globalThis as { window?: unknown }).window = {
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
}
const ME_DATA = {
id: 1,
username: 'admin',
displayName: '裁判长',
role: 'admin',
mustChangePassword: false,
lastLoginAt: null,
sessionExpiresAt: '2026-09-12T00:31:00Z',
}
const BOUNDS = { minHours: 1, maxHours: 720 }
test('validateSessionHours 只接受 1~720 的整数小时', async () => {
const { validateSessionHours } = await import('../components/system/sessionSettings')
for (const [raw, want] of [
['1', 1],
['24', 24],
['720', 720],
[' 48 ', 48],
[72, 72],
] as const) {
const got = validateSessionHours(raw, BOUNDS)
assert.equal(got.ok, true, `应接受 ${String(raw)}`)
assert.equal(got.ok ? got.hours : 0, want)
}
for (const raw of ['', ' ', '0', '721', '-1', '24.5', 'abc']) {
const got = validateSessionHours(raw, BOUNDS)
assert.equal(got.ok, false, `应拒绝 ${JSON.stringify(raw)}`)
if (!got.ok) assert.match(got.message, /登录有效期/)
}
})
test('describeSessionLifetime 用小时与天数说明「登录后能待多久」', async () => {
const { describeSessionLifetime } = await import('../components/system/sessionSettings')
const day = describeSessionLifetime(24)
assert.match(day, /24 小时/)
assert.match(day, /1 天/)
assert.match(day, /顺延/)
assert.match(describeSessionLifetime(36), /1\.5 天/)
assert.match(describeSessionLifetime(0), /未设置/)
})
test('readSessionExpiresAt 只认带时区的合法时间串', async () => {
const { readSessionExpiresAt } = await import('../components/system/sessionSettings')
assert.equal(readSessionExpiresAt(ME_DATA), '2026-09-12T00:31:00Z')
// 老服务端不下发这个字段、访客、以及字段被写坏的情况,都要当成「没有」。
assert.equal(readSessionExpiresAt({ ...ME_DATA, sessionExpiresAt: undefined }), null)
assert.equal(readSessionExpiresAt({ ...ME_DATA, sessionExpiresAt: '不是时间' }), null)
assert.equal(readSessionExpiresAt(null), null)
})
test('formatSessionExpiry 渲染成本机时间的「当前会话将于 … 过期」', async () => {
const { formatSessionExpiry } = await import('../components/system/sessionSettings')
assert.match(formatSessionExpiry('2026-09-12T00:31:00Z'), /^当前会话将于 \d{4}-\d{2}-\d{2} \d{2}:\d{2} 过期$/)
assert.equal(formatSessionExpiry(null), '当前会话的到期时间未知')
assert.equal(formatSessionExpiry(''), '当前会话的到期时间未知')
assert.equal(formatSessionExpiry('2026-13-45'), '当前会话的到期时间未知')
})
test('会话心跳重新拉 /api/auth/me 并刷新到期时间', async () => {
reply = () => ({ ok: true, data: ME_DATA })
const { useAuthStore } = await import('../stores/auth')
setActivePinia(createPinia())
const auth = useAuthStore()
await auth.load()
assert.equal(captured.url, '/api/auth/me')
assert.equal(auth.sessionExpiresAt, '2026-09-12T00:31:00Z')
// 服务端续期后下发新的到期时间,心跳要跟上(用户菜单与设置页显示的就是它)。
captured.url = ''
reply = () => ({ ok: true, data: { ...ME_DATA, sessionExpiresAt: '2026-09-13T00:31:00Z' } })
await auth.refreshSession()
assert.equal(captured.url, '/api/auth/me')
assert.equal(auth.sessionExpiresAt, '2026-09-13T00:31:00Z')
})
test('访客不发起心跳', async () => {
reply = () => ({ ok: true, data: { ...ME_DATA, role: 'guest', sessionExpiresAt: null } })
const { useAuthStore } = await import('../stores/auth')
setActivePinia(createPinia())
const auth = useAuthStore()
await auth.load()
assert.equal(auth.isGuest, true)
assert.equal(auth.sessionExpiresAt, null)
captured.url = ''
await auth.refreshSession()
assert.equal(captured.url, '', '访客不该发起心跳')
})
test('sessionSettingsApi 走 /api/system/session 读写登录有效期', async () => {
reply = () => ({
ok: true,
data: { sessionHours: 72, defaultSessionHours: 24, minHours: 1, maxHours: 720, expiresAt: null },
})
const { sessionSettingsApi } = await import('../components/system/systemApi')
const loaded = await sessionSettingsApi.load()
assert.equal(captured.url, '/api/system/session')
assert.equal(captured.method, 'GET')
assert.equal(loaded.sessionHours, 72)
assert.equal(loaded.minHours, 1)
assert.equal(loaded.maxHours, 720)
reply = () => ({
ok: true,
data: { sessionHours: 1, defaultSessionHours: 24, minHours: 1, maxHours: 720, expiresAt: '2026-09-11T12:00:00Z' },
})
const saved = await sessionSettingsApi.save(1)
assert.equal(captured.url, '/api/system/session')
assert.equal(captured.method, 'PUT')
assert.equal(captured.body?.sessionHours, 1)
// 写请求带幂等用的 requestId(服务端据此防重复提交)。
assert.equal(typeof captured.body?.requestId, 'string')
assert.equal(saved.sessionHours, 1)
assert.equal(saved.expiresAt, '2026-09-11T12:00:00Z')
})
+171
View File
@@ -0,0 +1,171 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createPinia, setActivePinia } from 'pinia'
import type { EventSettings } from '@/shared'
/**
* 分批结算的前端契约:设置读取的默认值、未结算清单的路径与字段名、立即结算的请求体。
*
* 路径或字段名写错时现场看到的是「提示不出现 / 结算按错组生效」这类静默故障,
* 所以这里用 stub fetch 钉住真正发出去的请求。
*
* ⚠️ 共享 ApiClient 在模块初始化时就把 globalThis.fetch 绑死了,所以这里:
* 1. 不在文件顶部静态导入任何会构造 client 的模块(本文件的 import 都是类型或无副作用模块);
* 2. 桩在模块体里先装好,再用动态 import 拿被测模块;
* 3. 桩的响应由每个用例改的 responder 决定,多个用例共用一个桩。
*/
interface Captured {
url: string
method: string
headers: Record<string, string>
body: Record<string, unknown> | null
}
const captured: Captured = { url: '', method: '', headers: {}, body: null }
let responder: (request: Captured) => Response = () =>
new Response(JSON.stringify({ ok: true, data: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
captured.url = String(input)
captured.method = init?.method ?? 'GET'
captured.headers = (init?.headers ?? {}) as Record<string, string>
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
return responder(captured)
}) as typeof fetch
// systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。
;(globalThis as { window?: unknown }).window = {
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
const SETTINGS = {
event: { name: '测试赛事', subtitle: null, host: null, logoPath: null, primaryColor: null },
display: { background: null, logo: null, title: '测试赛事', subtitle: null, primaryColor: null, rotateSeconds: 15 },
standings: {
individualToTeamWeight: 1,
rankingMode: 'points',
showIndividualBoard: true,
showIndividualMedals: true,
},
defaultProfileId: null,
requireApproval: false,
}
test('liveOnPublishOf:缺字段 / 旧服务端一律按「实时结算」处理', async () => {
const { liveOnPublishOf } = await import('../components/system/systemApi')
assert.equal(liveOnPublishOf(null), true)
assert.equal(liveOnPublishOf(undefined), true)
assert.equal(liveOnPublishOf(SETTINGS as unknown as EventSettings), true)
assert.equal(
liveOnPublishOf({
...SETTINGS,
standings: { ...SETTINGS.standings, liveOnPublish: true },
} as unknown as EventSettings),
true,
)
assert.equal(
liveOnPublishOf({
...SETTINGS,
standings: { ...SETTINGS.standings, liveOnPublish: false },
} as unknown as EventSettings),
false,
)
})
test('settlementApi.status 读 GET /api/results/settlement 并归一未结算清单', async () => {
const { settlementApi } = await import('../components/system/systemApi')
responder = () =>
jsonResponse({
ok: true,
data: {
liveOnPublish: false,
groups: [
{ eventId: 3, categoryId: 7, categoryName: '男子甲组', required: 8, published: 4 },
{ eventId: 3, categoryId: 8, categoryName: '女子甲组', required: 6, published: 6 },
],
},
})
const status = await settlementApi.status(3, 7)
assert.equal(captured.url, '/api/results/settlement?eventId=3&categoryId=7')
assert.equal(captured.method, 'GET')
assert.equal(status.liveOnPublish, false)
assert.deepEqual(status.groups[0], {
eventId: 3,
categoryId: 7,
categoryName: '男子甲组',
required: 8,
published: 4,
})
assert.equal(status.groups.length, 2)
})
test('settlementApi.status 不带组别时只带 eventId', async () => {
const { settlementApi } = await import('../components/system/systemApi')
responder = () => jsonResponse({ ok: true, data: { liveOnPublish: true, groups: [] } })
const status = await settlementApi.status(9)
assert.equal(captured.url, '/api/results/settlement?eventId=9')
assert.equal(status.liveOnPublish, true)
assert.deepEqual(status.groups, [])
})
test('settlementApi.settle 发 POST /api/results/settle 并带 requestId', async () => {
const { settlementApi } = await import('../components/system/systemApi')
responder = () => jsonResponse({ ok: true, data: [] })
await settlementApi.settle(3, 7)
assert.equal(captured.url, '/api/results/settle')
assert.equal(captured.method, 'POST')
assert.equal(captured.body?.eventId, 3)
assert.equal(captured.body?.categoryId, 7)
// 服务端按 requestId 幂等:不带就会被当成重复提交。
assert.equal(typeof captured.headers['X-Request-Id'], 'string')
})
test('保存结算方式 PUT /api/settings,提交完整 standings 并回写 event store', async () => {
responder = () =>
jsonResponse({
ok: true,
data: { ...SETTINGS, standings: { ...SETTINGS.standings, liveOnPublish: false } },
})
const { liveOnPublishOf } = await import('../components/system/systemApi')
const { useEventStore } = await import('../stores/event')
const { useBaseData } = await import('../components/baseData/useBaseData')
setActivePinia(createPinia())
const event = useEventStore()
event.settings = { ...SETTINGS } as unknown as EventSettings
const data = useBaseData()
const saved = await data.saveLiveOnPublish(false)
assert.equal(captured.url, '/api/settings')
assert.equal(captured.method, 'PUT')
const standings = captured.body?.standings as Record<string, unknown>
assert.equal(standings.liveOnPublish, false)
// 只提交一个字段会把其余口径留在服务端旧值上,所以整个 standings 子对象原样回传。
assert.equal(standings.individualToTeamWeight, 1)
assert.equal(standings.rankingMode, 'points')
assert.equal(standings.showIndividualBoard, true)
assert.equal(liveOnPublishOf(saved), false)
// 录入页提示直接读 store 里的设置,不回写就等于「保存成功但提示没变」。
assert.equal(liveOnPublishOf(event.settings), false)
assert.equal(liveOnPublishOf(data.settings.value), false)
})
+80 -25
View File
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { fitLiveRowFontPx, liveResultColumnPx } from '../../fitFont'
import { LIVE_REFRESH_INTERVAL_MS, fallbackLiveSources, pickLiveColumns } from '../../live'
import {
LIVE_REFRESH_INTERVAL_MS,
fallbackLiveSources,
liveBlockRowCount,
liveBoardLayout,
liveRowBlocks,
pickLiveColumns,
} from '../../live'
import type { LiveEventSource } from '../../live'
import { displayName, displayResult } from '../../results'
import { useScoreboard } from '../../useScoreboard'
@@ -47,18 +54,28 @@ const rowCount = computed(() =>
boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0),
)
/**
* 版式由「几个项目 + 一栏几行」决定(见 live.ts):一个项目且行数多时,一栏拆成左右两块,
* 让字号不再被高度压到 34px。两块的行数相同,所以字号取一块的行数。
*/
const layout = computed(() => liveBoardLayout(boards.value.length, rowCount.value))
// 成绩列宽与字号互相牵制,所以由页面把一屏的成绩文本收齐了一次算完:行数 → 基准字号,
// 文本宽度 → 字号上限,字号 + 文本宽度 → 列宽。两栏各算各的会让两栏列宽不一致。
const resultTexts = computed(() =>
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
)
const rowFontPx = computed(() =>
fitLiveRowFontPx(rowCount.value, boards.value.length, resultTexts.value),
fitLiveRowFontPx(
liveBlockRowCount(rowCount.value, layout.value),
layout.value,
resultTexts.value,
),
)
const boardStyle = computed(() => ({
'--row-font': `${rowFontPx.value}px`,
'--rs-width': `${liveResultColumnPx(resultTexts.value, boards.value.length, rowFontPx.value)}px`,
'--rs-width': `${liveResultColumnPx(resultTexts.value, layout.value, rowFontPx.value)}px`,
}))
// 定时兜底:项目切换靠 scheduled_at 自己过期,快照不会为时间流逝推送,所以必须有这条轮询。
@@ -91,26 +108,28 @@ onUnmounted(() => {
<span v-if="board.category" class="cat">{{ board.category }}</span>
<span v-if="board.hiddenGroups > 0" class="more">另有 {{ board.hiddenGroups }} 组</span>
</header>
<div v-if="board.rows.length > 0" class="rows">
<div class="row head">
<span class="rk">名次</span>
<span class="nm">运动员 / 队</span>
<span class="team">单位</span>
<span class="rs">成绩</span>
</div>
<div
v-for="row in board.rows"
:key="row.entryId"
class="row"
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span>
<span class="team">
<TeamLogo :path="row.logoPath" />
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
</span>
<span class="rs">{{ displayResult(row) }}</span>
<div v-if="board.rows.length > 0" class="rows" :class="{ split: layout === 'split' }">
<div v-for="(block, index) in liveRowBlocks(board.rows, layout)" :key="index" class="blk">
<div class="row head">
<span class="rk">名次</span>
<span class="nm">运动员 / 队</span>
<span class="team">单位</span>
<span class="rs">成绩</span>
</div>
<div
v-for="row in block"
:key="row.entryId"
class="row"
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
>
<span class="rk">{{ row.rank ?? '—' }}</span>
<span class="nm">{{ displayName(row) }}</span>
<span class="team">
<TeamLogo :path="row.logoPath" />
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
</span>
<span class="rs">{{ displayResult(row) }}</span>
</div>
</div>
</div>
<div v-else class="board-empty">该项目暂无已发布的成绩</div>
@@ -139,9 +158,15 @@ onUnmounted(() => {
padding: 18px 20px;
overflow: hidden;
}
/* 只有一个项目时不必铺满 1920(一行字太长反而难读),居中留白与分项成绩页一致。 */
/* 只有一个项目时不再把卡片收窄居中。
*
* 收窄的旧理由是「一行字太长反而难读」,但那条说的是连续文字;这张是一张表——名次、单位、
* 成绩都各占一列、位置由列宽固定,视线是沿行走的,行宽不会影响读法。1920 的屏上收窄
* 1180 等于左右各留 370px 空白,而且收窄也换不来更大的字(字号由高度决定,与宽度无关):
* 那是纯粹浪费。铺满后左右只剩页框自带的 78px 边距,和顶栏、轮播条对齐。
*/
.boards-1 .board {
max-width: 1180px;
max-width: none;
}
.board-head {
display: flex;
@@ -176,6 +201,22 @@ h3 {
margin-top: 8px;
overflow: hidden;
}
/* 一块 = 表头 + 它的名次行。单栏版式下这一块从顶部开始排:表头要紧贴上面那条项目名,
行高封顶省下来的高度留在面板底部(行数是会长的,空在下面比空在中间像回事)。 */
.blk {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
}
/* 行数多到一张表压不出字(≥7 行)时,一栏拆成左右两块并排:半屏换来的高度直接变成字号。 */
.boards-1 .rows.split {
display: grid;
grid-template-columns: 1fr 1fr;
align-content: stretch;
align-items: stretch;
gap: 0 26px;
}
.row {
display: grid;
/* 成绩列宽由内容算(--rs-width,见 fitFont.ts):写死列宽时 56px 字号的 `1:09.09`
@@ -191,6 +232,20 @@ h3 {
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
animation: row-in 0.45s ease-out both;
}
/* 单栏满宽:名次列留足两位数(96px 字号下 `10` 的墨迹约 134px),运动员与单位各占一半弹性宽度。
行高再封顶 3.2 倍字号——行数少时(尤其只有 1 行)不把行拉满整屏高:96px 的字铺在 460px 高的行里
四周全是空的,封顶后 1 行约占可用高度的七成,余下的高度留在面板底部。
这两条只加在单栏版式上:两个项目时每栏的行数可能悬殊,行高拉伸与否维持原样。 */
.boards-1 .row {
grid-template-columns: 170px minmax(0, 1fr) minmax(0, 1fr) var(--rs-width, 220px);
gap: 18px;
max-height: calc(var(--row-font, 34px) * 3.2);
}
/* 拆成两块后每块只剩半屏宽:名次列窄一档但必须装得下两位数(56px 字号下 `10` 约 78px),
运动员列分到的宽度比单位多一半,4 个汉字的名字才不会被省略号截掉(实测 4 字名 @56px 约 224px)。 */
.boards-1 .rows.split .row {
grid-template-columns: 88px minmax(0, 1.5fr) minmax(0, 1fr) var(--rs-width, 150px);
}
.boards-2 .row {
/* 两栏时名次 / 单位也窄一档,成绩列同样跟着内容走。 */
grid-template-columns: 60px minmax(0, 1fr) 190px var(--rs-width, 150px);
+55 -22
View File
@@ -18,11 +18,19 @@ const EMPTY_ROW_FONT_PX = 48
*
* 行高由 flex 均分,这里按「可用高度 ÷ 行数 × 比例」给出字号,
* 比例留出 padding 与行距,保证文字始终装得下。
*
* capPx 用于实时赛况「单栏满宽」那种一栏占满整屏的版式:栏宽不是限制时,
* 半屏版式的 56px 观感上限会白白浪费掉高度换来的字号空间。
*/
export function fitRowFontPx(count: number, availablePx: number, ratio = 0.62): number {
export function fitRowFontPx(
count: number,
availablePx: number,
ratio = 0.62,
capPx = MAX_ROW_FONT_PX,
): number {
if (count <= 0) return EMPTY_ROW_FONT_PX
const perRow = availablePx / count
return Math.floor(Math.min(MAX_ROW_FONT_PX, Math.max(MIN_ROW_FONT_PX, perRow * ratio)))
return Math.floor(Math.min(capPx, Math.max(MIN_ROW_FONT_PX, perRow * ratio)))
}
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
@@ -30,13 +38,37 @@ export function fitRowFont(count: number, availablePx: number, ratio = 0.62): st
}
/**
* 实时赛况一屏分几栏 → 行高比例。
* 实时赛况的三种版式,与 LivePage.vue 的网格一一对应。
*
* 与 LivePage.vue 的 .boards-1 / .boards-2 网格同源:两栏时每栏只有半屏宽,
* 行高(也就是字号)要再收一档。
* - single:单项目一栏,且行数少到可以铺满整屏一张表(行号少,字号吃得下满宽);
* - split :单项目一栏,但行数多到「一张表」只能压出很小的字——把名次拆成左右两块并排,
* 半屏换来的高度直接变成字号(10 行:34px → 56px);
* - duo :两个项目左右各一栏(每栏半屏,宽高都紧张)。
*/
export function liveRowFontRatio(columns: number): number {
return columns > 1 ? 0.48 : 0.62
export type LiveLayout = 'single' | 'split' | 'duo'
interface LiveLayoutSpec {
/** 行高比例:可用高度 ÷ 行数 × 比例,就是字号。 */
ratio: number
/** 字号上限。 */
capPx: number
/** 成绩列宽下限。 */
resultFloorPx: number
/** 成绩列宽预算:成绩列最多占这么宽,再宽就得靠收字号解决。 */
resultBudgetPx: number
}
/**
* 各版式的四个数字。
*
* duo 沿用改前的一整套(0.48 / 56 / 150 / 240),两栏裁切刚修好,别动它。
* split 的行高比例回到 0.62:拆两块之后每块只有半屏宽、却是整屏高,行数也减半
* (最多 5 行),限制来自宽度而不是高度。
*/
const LIVE_LAYOUT_SPECS: Record<LiveLayout, LiveLayoutSpec> = {
single: { ratio: 0.62, capPx: 96, resultFloorPx: 220, resultBudgetPx: 440 },
split: { ratio: 0.62, capPx: MAX_ROW_FONT_PX, resultFloorPx: 150, resultBudgetPx: 300 },
duo: { ratio: 0.48, capPx: MAX_ROW_FONT_PX, resultFloorPx: 150, resultBudgetPx: 240 },
}
/**
@@ -45,19 +77,19 @@ export function liveRowFontRatio(columns: number): number {
* 保留下限是为了「短成绩时观感不变」:`59.99` 这类文本本来就装得下,列宽不该因为
* 改成按文本算就抖一下。
*/
export function liveResultColumnFloorPx(columns: number): number {
return columns > 1 ? 150 : 220
export function liveResultColumnFloorPx(layout: LiveLayout): number {
return LIVE_LAYOUT_SPECS[layout].resultFloorPx
}
/**
* 成绩列宽度预算(px):成绩列最多占这么宽,再宽就得靠收字号解决。
*
* 两栏时一行可用宽约 815px,扣掉名次 60 / 单位 190 / 间距 24 后剩 541px 由「运动员 + 成绩」
* 分。预算 240px 给成绩,运动员列还能留 300px(56px 字号下约 5 个汉字);名字本来就是
* 可以省略号截断的次要信息,末位成绩被裁才是不能接受的(`1:09.0` 会被读成另一个成绩)。
* 成绩列宽度预算(px)。半屏版式(split / duo)一行可用宽约 815px,扣掉名次、单位后剩 541px
* 由「运动员 + 成绩」分:预算 240–300px 给成绩,运动员列还能留 240px 以上(56px 字号下约 4 个
* 汉字);名字本来就是可以省略号截断的次要信息,末位成绩被裁才是不能接受的
* (`1:09.0` 会被读成另一个成绩)。满宽版式(single)一行有 1700px 可用,预算给到 440px,
* 连 `64:21.50` 这种长成绩都不用收字号。
*/
export function liveResultColumnBudgetPx(columns: number): number {
return columns > 1 ? 240 : 340
export function liveResultColumnBudgetPx(layout: LiveLayout): number {
return LIVE_LAYOUT_SPECS[layout].resultBudgetPx
}
/** 估宽时额外留的像素:抹掉 ceil 与亚像素误差,避免差 1px 又把末位数字裁掉。 */
@@ -95,18 +127,19 @@ function widestResultEm(results: readonly string[]): number {
*
* 真机缺陷:两栏时成绩列写死 150px,而 `1:09.09` 在 56px 字号下墨迹 201px,末位数字被外层
* overflow:hidden 裁掉;1–6 行时字号都大于 41px(150px 列的容限),于是全中招。这里把
* 「装不下就收字号」当兜底:典型成绩(4.2em)在 240px 预算下仍能取满 56px,靠加宽成绩列解决;
* 「装不下就收字号」当兜底:典型成绩(4.2em)在预算内仍能取满该版式的上限,靠加宽成绩列解决;
* 只有 `64:21.50` 这类特别长的成绩才真的收字号。
*/
export function fitLiveRowFontPx(
rowCount: number,
columns: number,
layout: LiveLayout,
results: readonly string[],
): number {
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, liveRowFontRatio(columns))
const spec = LIVE_LAYOUT_SPECS[layout]
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, spec.ratio, spec.capPx)
const widest = widestResultEm(results)
if (widest <= 0) return base
const byWidth = Math.floor((liveResultColumnBudgetPx(columns) - RESULT_COLUMN_PAD_PX) / widest)
const byWidth = Math.floor((spec.resultBudgetPx - RESULT_COLUMN_PAD_PX) / widest)
return Math.max(MIN_ROW_FONT_PX, Math.min(base, byWidth))
}
@@ -119,9 +152,9 @@ export function fitLiveRowFontPx(
*/
export function liveResultColumnPx(
results: readonly string[],
columns: number,
layout: LiveLayout,
fontPx: number,
): number {
const need = Math.ceil(widestResultEm(results) * fontPx) + RESULT_COLUMN_PAD_PX
return Math.max(liveResultColumnFloorPx(columns), need)
return Math.max(liveResultColumnFloorPx(layout), need)
}
+38
View File
@@ -1,4 +1,5 @@
import type { EventResultGroup, EventResultRow } from '@/shared'
import type { LiveLayout } from './fitFont'
/**
* 实时赛况页的纯逻辑:谁上屏、怎么分栏、栏内排哪些行、什么时候该发下一次请求。
@@ -18,6 +19,16 @@ export const MAX_LIVE_COLUMNS = 2
*/
export const MAX_LIVE_ROWS = 10
/**
* 单项目一栏时,行数到几行就把名次拆成左右两块并排。
*
* 阈值取 7 是因为它正好是「拆开之后字更大」的临界点:字号按 BOARD_AVAILABLE_PX 与高度比例
* 0.62 算(见 fitFont.ts),一张表铺满时 7 行只剩 49px(低于半屏版式的 56px 上限),而 6 行
* 还有 57px。7 行起拆成两块(每块 ≤5 行)换来的字号永远更大,再往下(8/9/10 行)差距越拉越大;
* fitFont.test.ts 里有一条测试盯着这个临界点。
*/
export const LIVE_SPLIT_MIN_ROWS = 7
/**
* 定时兜底刷新间隔。
*
@@ -116,6 +127,33 @@ export function liveRefreshDelay(
return Math.max(0, minGapMs - (now - lastAttemptAt))
}
/**
* 单栏拆几块:只有「一个项目 + 行数够多」才拆,两个项目时本来就是一栏一块。
*/
export function liveBoardLayout(columns: number, rowCount: number): LiveLayout {
if (columns > 1) return 'duo'
return rowCount >= LIVE_SPLIT_MIN_ROWS ? 'split' : 'single'
}
/**
* 字号要按「一块里有几行」算,而不是整栏几行——拆两块的意义就在这里。
*/
export function liveBlockRowCount(rowCount: number, layout: LiveLayout): number {
return layout === 'split' ? Math.ceil(rowCount / 2) : rowCount
}
/**
* 把一栏的名次切成左右两块:上半(名次靠前)在左,下半在右。
*
* 前一半多一行(7 行 → 4 + 3),让更重要的名次留在左块;两块的行数最多差 1 行,
* 行高才不会一块紧一块松。非 split 版式原样返回单块。
*/
export function liveRowBlocks<T>(rows: readonly T[], layout: LiveLayout): T[][] {
if (layout !== 'split' || rows.length < LIVE_SPLIT_MIN_ROWS) return [rows as T[]]
const half = Math.ceil(rows.length / 2)
return [rows.slice(0, half), rows.slice(half)]
}
/** 同一小项分了多个组别时取最近发布的一组;发布时间缺失按最旧算。 */
function pickLatestGroup(groups: readonly EventResultGroup[]): EventResultGroup | null {
let latest: EventResultGroup | null = null
+58 -27
View File
@@ -10,6 +10,7 @@ import {
liveResultColumnPx,
resultTextEm,
} from '../fitFont'
import { LIVE_SPLIT_MIN_ROWS } from '../live'
test('fitRowFont 空列表用固定字号', () => {
assert.equal(fitRowFont(0, BOARD_AVAILABLE_PX), '48px')
@@ -39,11 +40,11 @@ test('两栏 1–10 行:字号封顶 56px、墨迹一律装得进算出来的
const widest = resultTextEm('1:06.33')
const expectedFont = [56, 56, 56, 56, 53, 44, 38, 33, 29, 26]
for (let rows = 1; rows <= 10; rows++) {
const font = fitLiveRowFontPx(rows, 2, results)
const font = fitLiveRowFontPx(rows, 'duo', results)
assert.equal(font, expectedFont[rows - 1], `${rows} 行`)
assert.ok(font <= MAX_ROW_FONT_PX)
const column = liveResultColumnPx(results, 2, font)
assert.ok(column >= liveResultColumnFloorPx(2), `${rows} 行不该比原来的 150px 更窄`)
const column = liveResultColumnPx(results, 'duo', font)
assert.ok(column >= liveResultColumnFloorPx('duo'), `${rows} 行不该比原来的 150px 更窄`)
assert.ok(widest * font <= column, `${rows} 行仍会裁`)
}
})
@@ -52,54 +53,84 @@ test('两栏 1–7 行才需要加宽成绩列,8 行以上仍用原来的 150p
const results = ['1:06.33']
const widest = resultTextEm('1:06.33')
for (let rows = 1; rows <= 7; rows++) {
const font = fitLiveRowFontPx(rows, 2, results)
const font = fitLiveRowFontPx(rows, 'duo', results)
// 写死 150px 时这些行数真的会裁:这正是本次要修的真机缺陷。
assert.ok(widest * font > liveResultColumnFloorPx(2), `${rows} 行`)
assert.ok(liveResultColumnPx(results, 2, font) >= widest * font)
assert.ok(widest * font > liveResultColumnFloorPx('duo'), `${rows} 行`)
assert.ok(liveResultColumnPx(results, 'duo', font) >= widest * font)
}
for (const rows of [8, 9, 10]) {
const font = fitLiveRowFontPx(rows, 2, results)
assert.ok(widest * font <= liveResultColumnFloorPx(2), `${rows} 行`)
assert.equal(liveResultColumnPx(results, 2, font), 150)
const font = fitLiveRowFontPx(rows, 'duo', results)
assert.ok(widest * font <= liveResultColumnFloorPx('duo'), `${rows} 行`)
assert.equal(liveResultColumnPx(results, 'duo', font), 150)
}
})
test('长成绩:先加宽列,装不下才收字号,且不越预算', () => {
const results = ['64:21.50']
const font = fitLiveRowFontPx(1, 2, results)
const font = fitLiveRowFontPx(1, 'duo', results)
assert.ok(font < MAX_ROW_FONT_PX, '特别长的成绩要让出字号')
assert.ok(font >= 40, '让出的幅度不该把大屏字变小')
const column = liveResultColumnPx(results, 2, font)
const column = liveResultColumnPx(results, 'duo', font)
assert.ok(resultTextEm('64:21.50') * font <= column)
assert.ok(column <= liveResultColumnBudgetPx(2))
assert.ok(column <= liveResultColumnBudgetPx('duo'))
// 典型成绩(7 字符)不该因为这条上限被收字号。
assert.equal(fitLiveRowFontPx(1, 2, ['1:06.33', '1:09.09']), 56)
assert.equal(fitLiveRowFontPx(1, 'duo', ['1:06.33', '1:09.09']), 56)
})
test('单栏:56px 上限不因宽度被削,短成绩仍用原来的 220px 列', () => {
assert.equal(fitLiveRowFontPx(1, 1, ['1:09.09']), MAX_ROW_FONT_PX)
assert.equal(fitLiveRowFontPx(6, 1, ['1:09.09']), MAX_ROW_FONT_PX)
assert.equal(liveResultColumnPx(['59.99'], 1, MAX_ROW_FONT_PX), liveResultColumnFloorPx(1))
// 单栏也会裁长成绩(220px 装不下 56px 的 `64:21.50`),同样由内容定宽修掉。
const font = fitLiveRowFontPx(1, 1, ['64:21.50'])
assert.equal(font, MAX_ROW_FONT_PX)
assert.ok(liveResultColumnPx(['64:21.50'], 1, font) >= resultTextEm('64:21.50') * font)
test('单栏满宽:字号用满高度(96px 上限),短成绩仍守着原来的 220px 列', () => {
// 收窄到 1180px 的旧版式把这里的字号压在 56px;铺满全场后高度换来的字号不再被削。
assert.equal(fitLiveRowFontPx(1, 'single', ['1:09.09']), 96)
assert.equal(fitLiveRowFontPx(3, 'single', ['1:09.09']), 96)
assert.equal(fitLiveRowFontPx(4, 'single', ['1:09.09']), 86)
assert.equal(fitLiveRowFontPx(6, 'single', ['1:09.09']), 57)
assert.equal(
liveResultColumnPx(['59.99'], 'single', MAX_ROW_FONT_PX),
liveResultColumnFloorPx('single'),
)
// 单栏也会裁长成绩(220px 装不下 96px 的 `64:21.50`),同样由内容定宽修掉。
const font = fitLiveRowFontPx(1, 'single', ['64:21.50'])
assert.equal(font, 89)
assert.ok(liveResultColumnPx(['64:21.50'], 'single', font) >= resultTextEm('64:21.50') * font)
})
test('拆两块:每块 ≤5 行时字号回到 56px 上限,且行数越多越只增不减', () => {
const results = ['1:06.33', '1:09.09', '1:10.55', '59.99']
for (const rows of [7, 8, 9, 10]) {
const perBlock = Math.ceil(rows / 2)
const font = fitLiveRowFontPx(perBlock, 'split', results)
assert.equal(font, MAX_ROW_FONT_PX, `${rows} 行`)
assert.ok(font > fitLiveRowFontPx(rows, 'single', results), `${rows} 行拆开必须更大`)
const column = liveResultColumnPx(results, 'split', font)
assert.ok(resultTextEm('1:06.33') * font <= column)
assert.ok(column <= liveResultColumnBudgetPx('split'))
}
})
test('临界点:拆分线起拆两块才划算(差一行时拆开反而更小)', () => {
const results = ['1:06.33', '1:09.09', '1:10.55', '59.99']
for (let rows = 1; rows <= 10; rows++) {
const single = fitLiveRowFontPx(rows, 'single', results)
const split = fitLiveRowFontPx(Math.ceil(rows / 2), 'split', results)
if (rows >= LIVE_SPLIT_MIN_ROWS) assert.ok(split > single, `${rows} 行应当拆`)
else assert.ok(split <= single, `${rows} 行不该拆`)
}
})
test('没有行可排时退回固定字号与列宽下限', () => {
assert.equal(fitLiveRowFontPx(0, 2, []), 48)
assert.equal(liveResultColumnPx([], 2, 56), liveResultColumnFloorPx(2))
assert.equal(liveResultColumnPx([], 1, 56), liveResultColumnFloorPx(1))
assert.equal(fitLiveRowFontPx(0, 'duo', []), 48)
assert.equal(liveResultColumnPx([], 'duo', 56), liveResultColumnFloorPx('duo'))
assert.equal(liveResultColumnPx([], 'single', 56), liveResultColumnFloorPx('single'))
assert.equal(liveResultColumnPx([], 'split', 56), liveResultColumnFloorPx('split'))
})
test('行数越多字号只减不增', () => {
const results = ['1:06.33', 'DNF', '12.35米']
for (const columns of [1, 2]) {
for (const layout of ['single', 'split', 'duo'] as const) {
let previous = Infinity
for (let rows = 1; rows <= 12; rows++) {
const font = fitLiveRowFontPx(rows, columns, results)
assert.ok(font <= previous, `${columns} 栏 ${rows} 行`)
const font = fitLiveRowFontPx(rows, layout, results)
assert.ok(font <= previous, `${layout} ${rows} 行`)
previous = font
}
}
+84
View File
@@ -3,9 +3,13 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { EventResultGroup, EventResultRow } from '@/shared'
import {
LIVE_SPLIT_MIN_ROWS,
MAX_LIVE_ROWS,
fallbackLiveSources,
liveBlockRowCount,
liveBoardLayout,
liveRefreshDelay,
liveRowBlocks,
liveRows,
pickLiveColumns,
} from '../live'
@@ -132,6 +136,44 @@ test('栏内按名次排序、无名次排最后,并截断到上限', () => {
assert.equal(liveRows(group(1, 1, null, many)).length, MAX_LIVE_ROWS)
})
test('版式判定:一个项目行少就满宽一张表,行多拆两块,两个项目左一右一', () => {
assert.equal(liveBoardLayout(1, 1), 'single')
assert.equal(liveBoardLayout(1, LIVE_SPLIT_MIN_ROWS - 1), 'single')
assert.equal(liveBoardLayout(1, LIVE_SPLIT_MIN_ROWS), 'split')
assert.equal(liveBoardLayout(1, MAX_LIVE_ROWS), 'split')
// 两个项目时本来就是一栏一块,不再二次拆分。
assert.equal(liveBoardLayout(2, 1), 'duo')
assert.equal(liveBoardLayout(2, MAX_LIVE_ROWS), 'duo')
})
test('字号按「一块几行」算:拆两块后每块行数是整栏的一半(向上取整)', () => {
assert.equal(liveBlockRowCount(MAX_LIVE_ROWS, 'split'), 5)
assert.equal(liveBlockRowCount(LIVE_SPLIT_MIN_ROWS, 'split'), 4)
assert.equal(liveBlockRowCount(8, 'split'), 4)
assert.equal(liveBlockRowCount(9, 'split'), 5)
// 不拆的版式就是整栏行数。
assert.equal(liveBlockRowCount(6, 'single'), 6)
assert.equal(liveBlockRowCount(4, 'single'), 4)
assert.equal(liveBlockRowCount(MAX_LIVE_ROWS, 'duo'), MAX_LIVE_ROWS)
})
test('拆两块:名次靠前的左块多一行,两块行数最多差 1,非 split 原样一块', () => {
const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert.deepEqual(liveRowBlocks(ten, 'single'), [ten])
assert.deepEqual(liveRowBlocks(ten, 'duo'), [ten])
const seven = liveRowBlocks(ten.slice(0, 7), 'split')
assert.deepEqual(seven[0], [1, 2, 3, 4])
assert.deepEqual(seven[1], [5, 6, 7])
assert.deepEqual(liveRowBlocks(ten, 'split').map((block) => block.length), [5, 5])
assert.deepEqual(liveRowBlocks(ten.slice(0, 8), 'split').map((block) => block.length), [4, 4])
assert.deepEqual(liveRowBlocks(ten.slice(0, 9), 'split').map((block) => block.length), [5, 4])
// 行数不到拆分线时不能拆:否则右块空着,一屏只剩半张表有内容。
assert.deepEqual(liveRowBlocks([1, 2, 3], 'split'), [[1, 2, 3]])
assert.deepEqual(liveRowBlocks([], 'split'), [[]])
})
test('接口还没成功过时的兜底素材:人工指定 id + 快照分组 + 小项名', () => {
const names = new Map([[7, '男子 1500 米']])
const sources = fallbackLiveSources([7, 3], [group(7), group(8)], names)
@@ -271,3 +313,45 @@ test('成绩列宽由内容算出来,网格里不能写死列宽', () => {
// 成绩不能折行:折成两行会被读成两个成绩,第二行还会被 .rows 竖着裁掉。
assert.match(source, /\.rs \{[^}]*white-space: nowrap/s)
})
test('单栏铺满页框:不再用 max-width 收窄居中', () => {
const source = readFileSync(
new URL('../components/pages/LivePage.vue', import.meta.url),
'utf8',
)
// 旧版式把卡片收窄到 1180px 居中,1920 的屏上左右各空 370px;而且收窄换不来更大的字
// (字号由高度决定),属于纯浪费。铺满后左右只剩页框自带的边距。
assert.doesNotMatch(source, /max-width: 1180px/)
assert.match(source, /\.boards-1 \.board \{[^}]*max-width: none/s)
})
test('版式接到渲染上:拆两块时表头跟着块走,字号按块内行数算', () => {
const source = readFileSync(
new URL('../components/pages/LivePage.vue', import.meta.url),
'utf8',
)
// 版式与分块都由 live.ts 判定,页面不许自己写死行数阈值。
assert.match(source, /liveBoardLayout\(boards\.value\.length, rowCount\.value\)/)
assert.match(source, /liveBlockRowCount\(rowCount\.value, layout\.value\)/)
assert.match(source, /liveRowBlocks\(board\.rows, layout\)/)
// 每一块都要有自己的表头,否则右块的名次/成绩没有列标题。
assert.match(source, /v-for="\(block, index\) in liveRowBlocks[\s\S]*class="row head"/)
assert.match(source, /v-for="row in block"/)
// 拆块时行不再拉满整屏高(1 行时 96px 的字铺在 460px 行里只会显空)。
assert.match(source, /max-height: calc\(var\(--row-font[^)]*\) \* 3\.2\)/)
})
test('拆两块时的名次列必须装得下两位数', () => {
const source = readFileSync(
new URL('../components/pages/LivePage.vue', import.meta.url),
'utf8',
)
// 56px 字号下 `10` 的墨迹约 78px:名次列窄于 80px 会被挤进运动员列。
const matched = /\.boards-1 \.rows\.split \.row \{[^}]*grid-template-columns: (\d+)px/s.exec(
source,
)
assert.ok(matched !== null, '拆块版式要单独给一套网格')
assert.ok(Number(matched[1]) >= 80, `名次列 ${matched[1]}px 装不下两位数`)
// 两个项目的版式(duo)沿用改前的网格,别跟着一起动。
assert.match(source, /\.boards-2 \.row \{[^}]*60px minmax\(0, 1fr\) 190px/s)
})
+88
View File
@@ -138,6 +138,18 @@ export interface EventsApi extends CrudApi<Event, EventInput> {
entries(eventId: number): Promise<EntryWithResult[]>
categories(eventId: number): Promise<EventCategory[]>
saveCategories(eventId: number, categories: EventCategoryInput[]): Promise<EventCategory[]>
/**
* 就地更新单个组别(`PUT /api/events/{id}/categories/{categoryId}`)。
*
* 与 saveCategories 的整表替换不同,服务端只发一条 UPDATE:组别 id 不变,
* 挂在它下面的报名、成绩、积分流水与名次全部原地保留。有报名数据的小项靠它
* 改名 / 改年龄 / 改性别 / 改权重;增删组别仍只能走 saveCategories(有报名数据时会被拒)。
*
* 注意:权重是算分输入——`multiplier` 真的变了时服务端会在同一事务里重算该组已发布成绩的
* 积分与名次(别人的分值/榜单会跟着变);name / gender / ageMin / ageMax / sortOrder 不重算。
* 返回该小项最新的组别列表。
*/
updateCategory(eventId: number, categoryId: number, category: EventCategoryInput): Promise<EventCategory[]>
channels(eventId: number): Promise<ScoreChannel[]>
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
}
@@ -167,6 +179,39 @@ export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
* 人员已被别人删掉时回 404。
*/
purge(id: number): Promise<AthletePurgeResult>
/**
* 批量停用 / 批量彻底删除(`POST /api/athletes/batch`)。
*
* 逐个调 DELETE 是「一次操作一条请求」,现场清理几十个人时会直接撞上写限流;
* 收成一次请求既少一次配额,响应里还逐条回报成败(有人已被别人删掉、有人 id 不存在)。
* dryRun = true 时只回报「会删掉什么、重算哪些小项」,不改库,供危险确认框先展示。
*/
batch(action: AthleteBatchAction, ids: number[], dryRun?: boolean): Promise<AthleteBatchResult>
}
/** 批量动作的语义:与单条停用 / 单条彻底删除完全一致。 */
export type AthleteBatchAction = 'disable' | 'purge'
/** 批量动作里逐条的结果:个别失败不能让整批失败,也不能静默跳过。 */
export interface AthleteBatchItem {
id: number
ok: boolean
/** 失败原因的错误码(目前只有 NOT_FOUND:该 id 在库里不存在)。 */
code?: string
message?: string
}
export interface AthleteBatchResult {
action: AthleteBatchAction
/** true 表示这是预览(dryRun),服务端一行都没动。 */
dryRun?: boolean
/** 去重后的 id 数。 */
requested: number
succeeded: number
failed: number
items: AthleteBatchItem[]
/** 只有 action = 'purge' 时出现:合计删除规模与受影响小项。 */
purge?: AthletePurgeResult
}
export interface TeamsApi extends CrudApi<Team, TeamInput> {
@@ -352,6 +397,35 @@ function readPurgeResult(value: unknown): AthletePurgeResult {
}
}
/**
* 读批量动作的响应。
*
* 逐条结果缺失时按「这一条失败」处理而不是成功:批量删除不可回滚,把没看到的 id 当成功
* 比多说一句「未能确认」危险得多。requested / succeeded / failed 也按 items 重算,
* 老服务端字段缺失时不至于报出「0 人成功」这种自相矛盾的结论。
*/
function readBatchResult(action: AthleteBatchAction, value: unknown): AthleteBatchResult {
const source = isRecord(value) ? value : {}
const items: AthleteBatchItem[] = Array.isArray(source['items'])
? source['items'].filter(isRecord).map((raw) => ({
id: typeof raw['id'] === 'number' ? raw['id'] : 0,
ok: raw['ok'] === true,
code: typeof raw['code'] === 'string' ? raw['code'] : undefined,
message: typeof raw['message'] === 'string' ? raw['message'] : undefined,
}))
: []
const purge = source['purge']
return {
action,
dryRun: source['dryRun'] === true,
requested: items.length,
succeeded: items.filter((item) => item.ok).length,
failed: items.filter((item) => !item.ok).length,
items,
purge: isRecord(purge) ? readPurgeResult(purge) : undefined,
}
}
function createTeamsApi(http: Http): TeamsApi {
return {
...createCrudApi<Team, TeamInput>(http, '/teams'),
@@ -366,6 +440,15 @@ function createAthletesApi(http: Http): AthletesApi {
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。
purge: async (id) =>
readPurgeResult(await http.request<unknown>(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })),
batch: async (action, ids, dryRun = false) =>
// dryRun 缺省不传:老服务端不认这个字段时仍按真删处理,不能被预览悄悄降级。
readBatchResult(
action,
await http.request<unknown>('/athletes/batch', {
method: 'POST',
body: dryRun ? { action, ids, dryRun: true } : { action, ids },
}),
),
}
}
@@ -379,6 +462,11 @@ function createEventsApi(http: Http): EventsApi {
method: 'PUT',
body: { categories },
}),
updateCategory: (eventId, categoryId, category) =>
http.request<EventCategory[]>(`/events/${eventId}/categories/${categoryId}`, {
method: 'PUT',
body: category,
}),
channels: (eventId) => http.request<ScoreChannel[]>(`/events/${eventId}/channels`),
saveChannels: (eventId, channels) =>
http.request<ScoreChannel[]>(`/events/${eventId}/channels`, {
+240
View File
@@ -0,0 +1,240 @@
/**
* 报名名单与基础数据包(单位 / 大项 / 小项 / 组别)的 xlsx 导入导出。
*
* 这两组接口没进 `shared/api.ts` 的 ApiClient(那是全站共用的客户端,本任务不改它),
* 单独放一个模块,导出页与测试都从这里取。
*
* 错误处理与全站一致:只有连不上 / 超时才可能是「后端未就绪」,
* 403 / 400 / 409 这些是服务端的真实回答,必须原样抛出,否则会把
* 「没权限」「表头写错了」伪装成示例数据。
*/
import { ApiError } from './api'
import type { ApiErrorCode } from './types'
const BASE_URL = '/api'
/** 导出要等服务端现算 xlsx,比普通读接口慢。 */
const DOWNLOAD_TIMEOUT_MS = 60_000
/** 上传整包要读全部工作表再写库,弱网下更慢。 */
const UPLOAD_TIMEOUT_MS = 120_000
const API_ERROR_CODES: readonly string[] = [
'AUTH_REQUIRED',
'AUTH_FAILED',
'FORBIDDEN',
'VALIDATION_FAILED',
'CONFLICT',
'STATE_INVALID',
'RATE_LIMITED',
'INTERNAL',
'NETWORK',
'TIMEOUT',
'PARSE_FAILED',
]
export const ENTRIES_XLSX_FILENAME = '报名名单.xlsx'
export const BASEDATA_XLSX_FILENAME = '基础数据包.xlsx'
export interface DownloadFile {
blob: Blob
filename: string
}
/** 报名名单的筛选条件;不传的字段 = 该维度不筛。 */
export interface EntryExportFilter {
eventId?: number
categoryId?: number
teamId?: number
heat?: number
}
export interface BaseDataFieldDiff {
field: string
from: string
to: string
}
/** 一行差异:哪张表、第几行、新增还是更新、哪个字段从什么变成什么。 */
export interface BaseDataChange {
sheet: string
line: number
action: string
key: string
fields: BaseDataFieldDiff[]
}
/**
* 差异预览 / 导入结果。
* `errors` 是行级中文错误(形如「单位 第 3 行:单位编码必填」),有错误就不会写库里那一行。
*/
export interface BaseDataImportResult {
dryRun: boolean
added: number
updated: number
unchanged: number
errorCount: number
errors: string[]
changes: BaseDataChange[]
}
/** 报名名单导出的请求路径(筛选参数为空就不带 query)。 */
export function entriesExportPath(filter: EntryExportFilter = {}): string {
const params = new URLSearchParams()
for (const key of ['eventId', 'categoryId', 'teamId', 'heat'] as const) {
const value = filter[key]
if (value !== undefined) params.append(key, String(value))
}
const query = params.toString()
return `/export/entries.xlsx${query === '' ? '' : `?${query}`}`
}
/** 下载报名名单(xlsx)。 */
export function downloadEntriesXLSX(filter: EntryExportFilter = {}): Promise<DownloadFile> {
return download(entriesExportPath(filter), ENTRIES_XLSX_FILENAME)
}
/** 下载基础数据包(多 sheet 工作簿)。 */
export function downloadBaseDataXLSX(): Promise<DownloadFile> {
return download('/export/basedata.xlsx', BASEDATA_XLSX_FILENAME)
}
/**
* 上传基础数据包。
* `dryRun=true` 只算差异不写库;用户确认后再用 `dryRun=false` 提交同一个文件。
*/
export async function importBaseDataXLSX(
file: File,
options: { dryRun?: boolean } = {},
): Promise<BaseDataImportResult> {
const form = new FormData()
form.append('file', file)
if (options.dryRun === true) form.append('dryRun', '1')
const response = await request('/export/basedata/import', {
method: 'POST',
body: form,
// 浏览器自己带 multipart 边界,显式设 Content-Type 反而会漏掉 boundary。
headers: csrfHeader(),
}, UPLOAD_TIMEOUT_MS)
const body = await readBody(response)
const record = isRecord(body) ? body : {}
if (record.ok !== true) {
throw toApiError(body, response.status)
}
const data = isRecord(record.data) ? record.data : {}
return {
dryRun: data.dryRun === true,
added: readNumber(data, 'added') ?? 0,
updated: readNumber(data, 'updated') ?? 0,
unchanged: readNumber(data, 'unchanged') ?? 0,
errorCount: readNumber(data, 'errorCount') ?? 0,
errors: readStrings(data.errors),
changes: readChanges(data.changes),
}
}
// ---------------------------------------------------------------------------
// 底层
// ---------------------------------------------------------------------------
async function download(path: string, filename: string): Promise<DownloadFile> {
const response = await request(path, { method: 'GET' })
return { blob: await response.blob(), filename }
}
async function request(path: string, init: RequestInit, timeoutMs = DOWNLOAD_TIMEOUT_MS): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(`${BASE_URL}${path}`, {
credentials: 'include',
...init,
signal: controller.signal,
})
if (!response.ok) {
throw toApiError(await readBody(response), response.status)
}
return response
} catch (error) {
if (error instanceof ApiError) throw error
if (controller.signal.aborted) {
throw new ApiError('TIMEOUT', `请求超时(${timeoutMs}ms)`, 0, error)
}
throw new ApiError('NETWORK', '无法连接服务端', 0, error)
} finally {
clearTimeout(timer)
}
}
async function readBody(response: Response): Promise<unknown> {
if (response.status === 204) return null
const text = await response.text()
if (text.trim() === '') return null
try {
return JSON.parse(text)
} catch {
return text
}
}
function csrfHeader(): Record<string, string> {
if (typeof document === 'undefined') return {}
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
const raw = match?.[1]
if (raw === undefined) return {}
return { 'X-CSRF-Token': decodeURIComponent(raw) }
}
function toApiError(body: unknown, status: number): ApiError {
const record = isRecord(body) ? body : {}
const error = isRecord(record.error) ? record.error : {}
const code = typeof error.code === 'string' && API_ERROR_CODES.includes(error.code)
? (error.code as ApiErrorCode)
: 'INTERNAL'
const message = typeof error.message === 'string' && error.message !== ''
? error.message
: `请求失败(HTTP ${status})`
return new ApiError(code, message, status, error.detail)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readNumber(source: Record<string, unknown>, key: string): number | null {
const value = source[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function readStrings(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === 'string')
}
function readChanges(value: unknown): BaseDataChange[] {
if (!Array.isArray(value)) return []
const out: BaseDataChange[] = []
for (const item of value) {
if (!isRecord(item)) continue
const fields: BaseDataFieldDiff[] = []
if (Array.isArray(item.fields)) {
for (const field of item.fields) {
if (!isRecord(field)) continue
fields.push({
field: typeof field.field === 'string' ? field.field : '',
from: typeof field.from === 'string' ? field.from : '',
to: typeof field.to === 'string' ? field.to : '',
})
}
}
out.push({
sheet: typeof item.sheet === 'string' ? item.sheet : '',
line: readNumber(item, 'line') ?? 0,
action: typeof item.action === 'string' ? item.action : '',
key: typeof item.key === 'string' ? item.key : '',
fields,
})
}
return out
}
+15 -3
View File
@@ -182,12 +182,12 @@ export interface ImportConflictFields {
export type ImportConflictField = keyof ImportConflictFields
/** 同一证件号 / 号码布在库里与报名表中的人员信息不一致;本轮不落库,等用户决策。 */
/** 号码布 / 姓名 + 单位命中的人与报名表里的人员信息不一致;本轮不落库,等用户决策。 */
export interface ImportConflict {
/** 报名表原始行号(含表头,从 1 开始)。 */
row: number
idCard: string
/** 报名表该行的号码布;证件号为空时它就是这条冲突的决策键。 */
/** 报名表该行的号码布;面板按号码布优先给它分决策键(decisionKeyOf)。 */
bib: string
athleteId: number
existing: ImportConflictFields
@@ -198,13 +198,23 @@ export interface ImportConflict {
/** keep-existing = 保留库里已有的人员字段;keep-new = 用表里的字段更新该人员。 */
export type ConflictAction = 'keep-existing' | 'keep-new'
/** 决策键由服务端判定:证件号优先,为空时取号码布,因此两个字段都要带。 */
/** 服务端按证件号优先、为空时取号码布重算决策键,因此两个字段都要带。 */
export interface ImportConflictDecision {
idCard: string
bib: string
action: ConflictAction
}
/** 号码布不同 → 按「同名的另一个人」新建档案的一行;只作报告,不拦截导入。 */
export interface SameNameNewPerson {
name: string
teamCode: string
/** 表里给的新号码布,也是新建档案用的号码布。 */
newBib: string
/** 库里同名同单位那个人已有的号码布。 */
existingBib: string
}
export interface EntryImportResult {
created: number
updated: number
@@ -212,6 +222,8 @@ export interface EntryImportResult {
errors: string[]
/** 本轮未落库的人员冲突;resolve 接口返回的则是剩余未决策项。 */
conflicts: ImportConflict[]
/** 本次按「同名的另一个人」新建的档案(号码布不同);仅提示,不阻塞导入。 */
sameNameNewPersons: SameNameNewPerson[]
}
/** 批量设置批次(PUT /api/entries/heat)的请求体;heat 为 null 表示清除为「未分批」。 */