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'),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 字体(内置字体 + 上传字体)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user