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:
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')
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 字体(内置字体 + 上传字体)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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 个小项/)
|
||||
})
|
||||
@@ -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'), '未分批')
|
||||
})
|
||||
@@ -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), '双击实现不应引入延时')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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
@@ -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 表示清除为「未分批」。 */
|
||||
|
||||
Reference in new issue
Block a user