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

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

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

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

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

No files matched your search

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Athlete, AthleteInput, Gender } from '@/shared'
import type { AthletePurgeResult } from '@/shared/api'
import type { AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { baseDataApi } from './baseDataApi'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
@@ -47,6 +48,205 @@ const purgePending = ref(false)
/** 删除失败时的说明留在页面上:服务端那句原文(人员已不在等)比一行「操作失败」有用得多。 */
const purgeError = ref<string | null>(null)
// ── 多选与批量操作 ──────────────────────────────────────────────────────────
/**
* 勾选的运动员 id。
*
* 每行一个勾选框,表头是「全选当前筛选结果」——先按单位 / 状态 / 关键字把要处理的人筛出来,
* 再一键全选,是现场最高频的用法;如果表头连被筛掉的人一起选,批量彻底删除就可能删掉
* 看不见的人,所以勾选范围严格等于当前可见的筛选结果。
*/
const selectedIds = ref<Set<number>>(new Set())
/** 批量操作提交中的标志(预览与真删共用,避免重复点击)。 */
const batchPending = ref(false)
/** 批量操作的逐条结果常驻页面:谁没被处理、为什么,不能只靠一闪而过的 toast。 */
const batchMessage = ref<string | null>(null)
/** 发起批量操作时锁定的 id 列表:确认框开着的时候选择不该再变化,提交用的就是这一份。 */
const batchTargetIds = ref<number[]>([])
const batchDisableOpen = ref(false)
const batchPurgeOpen = ref(false)
/** 彻底删除的预览(dryRun 响应):提交前把「会删掉什么、重算哪些小项」摆给操作员看。 */
const bulkPurgePreview = ref<AthleteBatchResult | null>(null)
const filteredIds = computed(() => filtered.value.map((athlete) => athlete.id))
const selectedCount = computed(() => selectedIds.value.size)
const selectedAthletes = computed(() => data.athletes.value.filter((athlete) => selectedIds.value.has(athlete.id)))
const allFilteredSelected = computed(
() => filteredIds.value.length > 0 && filteredIds.value.every((id) => selectedIds.value.has(id)),
)
const someFilteredSelected = computed(
() => !allFilteredSelected.value && filteredIds.value.some((id) => selectedIds.value.has(id)),
)
// 被筛掉 / 被删掉的 id 自动退出选择:批量彻底删除的范围必须始终看得见。
watch(filteredIds, (ids) => {
if (selectedIds.value.size === 0) return
const visible = new Set(ids)
const kept = [...selectedIds.value].filter((id) => visible.has(id))
if (kept.length !== selectedIds.value.size) selectedIds.value = new Set(kept)
})
function toggleOne(id: number, checked: boolean): void {
const next = new Set(selectedIds.value)
if (checked) next.add(id)
else next.delete(id)
selectedIds.value = next
}
function onRowCheck(id: number, event: Event): void {
toggleOne(id, (event.target as HTMLInputElement).checked)
}
function toggleAllFiltered(checked: boolean): void {
const next = new Set(selectedIds.value)
for (const id of filteredIds.value) {
if (checked) next.add(id)
else next.delete(id)
}
selectedIds.value = next
}
function onHeaderCheck(event: Event): void {
toggleAllFiltered((event.target as HTMLInputElement).checked)
}
function clearSelection(): void {
selectedIds.value = new Set()
}
/** 提交前的名单快照:批量删除之后这些人可能已经不在列表里,名字要提前留住。 */
function selectedLabels(): Map<number, string> {
return new Map(selectedAthletes.value.map((athlete) => [athlete.id, athlete.name]))
}
/**
* 汇总一次批量动作的逐条结果。
*
* 有失败就常驻页面并逐条点名(谁、为什么),不能只报「部分成功」——
* 现场要拿着这句话去核对到底谁没被处理。
*/
function reportBatch(verb: string, result: AthleteBatchResult, labels: Map<number, string>): void {
const failures = result.items.filter((item) => !item.ok)
const done = `已${verb} ${result.succeeded} 人`
if (failures.length === 0) {
batchMessage.value = null
toast.success(`${done}(${result.requested} 人中全部成功)`)
return
}
const detail = failures
.map((item) => `${labels.get(item.id) ?? `#${item.id}`}(${item.message ?? '未能处理'})`)
.join('、')
batchMessage.value = `${done},${failures.length} 人未能处理:${detail}`
toast.error(`批量${verb}:${failures.length} 人未处理`, batchMessage.value)
}
function askBatchDisable(): void {
if (selectedCount.value === 0) return
batchTargetIds.value = [...selectedIds.value]
batchMessage.value = null
batchDisableOpen.value = true
}
async function confirmBatchDisable(): Promise<void> {
if (batchPending.value) return
const labels = selectedLabels()
batchPending.value = true
try {
const result = await baseDataApi.athletes.batch('disable', batchTargetIds.value)
reportBatch('停用', result, labels)
batchDisableOpen.value = false
clearSelection()
await data.load(true)
} catch (caught) {
toast.error('批量停用失败', caught instanceof Error ? caught.message : null)
} finally {
batchPending.value = false
}
}
/** 先预览再确认:彻底删除不可逆,必须让操作员看到会删掉谁、重算哪些小项。 */
async function askBatchPurge(): Promise<void> {
if (selectedCount.value === 0) return
batchTargetIds.value = [...selectedIds.value]
batchMessage.value = null
bulkPurgePreview.value = null
batchPending.value = true
try {
bulkPurgePreview.value = await baseDataApi.athletes.batch('purge', batchTargetIds.value, true)
batchPurgeOpen.value = true
} catch (caught) {
toast.error('无法预估删除影响', caught instanceof Error ? caught.message : null)
} finally {
batchPending.value = false
}
}
/** 确认框里逐行摆出的影响:名单、删除规模、要重算的小项,以及不可逆的提醒。 */
const bulkPurgeDetails = computed<string[]>(() => {
const preview = bulkPurgePreview.value
const people = data.athletes.value.filter((athlete) => batchTargetIds.value.includes(athlete.id))
const shown = people
.slice(0, 20)
.map((athlete) => `${athlete.name}(${data.teamById(athlete.teamId)?.name ?? '未知单位'})`)
const lines: string[] = [
`将删除 ${people.length} 人:${shown.join('、')}${people.length > shown.length ? ` 等 ${people.length} 人` : ''}`,
]
if (preview?.purge !== undefined) {
const p = preview.purge
const parts = [`报名 ${p.deletedEntries} 条`, `成绩 ${p.deletedResults} 条`]
if (p.deletedLedger > 0) parts.push(`积分流水 ${p.deletedLedger} 条`)
if (p.deletedBreaks > 0) parts.push(`纪录 ${p.deletedBreaks} 条`)
lines.push(`合计删除 ${parts.join('、')}`)
const events = p.recomputedEvents.map((id) => data.eventById(id)?.name ?? `#${id}`)
lines.push(
events.length > 0
? `会重算 ${events.length} 个小项的排名与积分:${events.join('、')}`
: '没有已发布的成绩,不需要重算名次',
)
}
const missing = (preview?.items ?? []).filter((item) => !item.ok)
if (missing.length > 0) {
lines.push(`其中 ${missing.length} 人已不在名单里,提交时会跳过并单独回报`)
}
if (people.length > 20) {
lines.push(`本次涉及 ${people.length} 人,人数较多时重算更久,请确认名单无误`)
}
lines.push('删除不可恢复;只是想暂时不让他报名,请改用「批量停用」')
return lines
})
async function confirmBatchPurge(): Promise<void> {
if (batchPending.value) return
const labels = new Map(
data.athletes.value.filter((athlete) => batchTargetIds.value.includes(athlete.id)).map((a) => [a.id, a.name]),
)
batchPending.value = true
try {
const result = await baseDataApi.athletes.batch('purge', batchTargetIds.value)
reportBatch('彻底删除', result, labels)
const recomputed = result.purge?.recomputedEvents.length ?? 0
if (recomputed > 0) {
// 名次与积分会随成绩一起变,别只说「已删除」:说清重算了几个小项,操作员才不会
// 回头发现榜单变了却找不到原因。
const note = `已重算 ${recomputed} 个小项的排名与积分`
batchMessage.value = batchMessage.value === null ? note : `${batchMessage.value};${note}`
}
batchPurgeOpen.value = false
clearSelection()
await data.load(true)
} catch (caught) {
// 服务端原文(整批回滚、某个人被引用住了)必须照原样展示。
const message = caught instanceof Error ? caught.message : '批量彻底删除失败'
batchMessage.value = message
toast.error('批量彻底删除失败', message)
batchPurgeOpen.value = false
} finally {
batchPending.value = false
}
}
function emptyForm(): AthleteForm {
return { name: '', gender: '', age: '', teamId: '', employeeNo: '', idCard: '', bib: '', active: true }
}
@@ -261,10 +461,46 @@ async function confirmPurge(): Promise<void> {
<MxIcon name="warning" :size="14" /> {{ purgeError }}
</div>
<div v-if="batchMessage !== null" class="mx-badge mx-badge-error purge-error">
<MxIcon name="warning" :size="14" /> {{ batchMessage }}
</div>
<div v-if="selectedCount > 0" class="bd-batch-bar">
<span class="mx-badge mx-badge-neutral">已选 {{ selectedCount }} 人</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="clearSelection">清空选择</button>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || batchPending"
@click="askBatchDisable"
>
批量停用
</button>
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || batchPending"
@click="askBatchPurge"
>
批量彻底删除
</button>
<span class="bd-note">勾选范围是当前筛选结果,整批只发一次请求</span>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th class="bd-cell-mini">
<input
type="checkbox"
:checked="allFilteredSelected"
:indeterminate="someFilteredSelected"
:disabled="!data.canWrite.value || filteredIds.length === 0"
title="全选当前筛选结果"
@change="onHeaderCheck"
/>
</th>
<th>姓名</th>
<th class="bd-cell-mini">性别</th>
<th class="bd-cell-mini">年龄</th>
@@ -278,7 +514,7 @@ async function confirmPurge(): Promise<void> {
</thead>
<tbody>
<tr v-if="data.loading.value && data.athletes.value.length === 0">
<td colspan="9">
<td colspan="10">
<div class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">加载中…</div>
@@ -287,6 +523,15 @@ async function confirmPurge(): Promise<void> {
</tr>
<template v-else>
<tr v-for="athlete in filtered" :key="athlete.id">
<td class="bd-cell-mini">
<input
type="checkbox"
:checked="selectedIds.has(athlete.id)"
:disabled="!data.canWrite.value"
:aria-label="`选择 ${athlete.name}`"
@change="onRowCheck(athlete.id, $event)"
/>
</td>
<td>{{ athlete.name }}</td>
<td>{{ athlete.gender ?? '—' }}</td>
<td>{{ athlete.age ?? '—' }}</td>
@@ -325,7 +570,7 @@ async function confirmPurge(): Promise<void> {
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="9">
<td colspan="10">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
@@ -337,7 +582,7 @@ async function confirmPurge(): Promise<void> {
</table>
</div>
<p class="bd-note">
启用:正常出现在名单与报名选择里;停用为软删除:历史报名与成绩保留,新报名不再可选该运动员,随时可以再启用;彻底删除:真删除,人员档案连同他名下的报名与成绩一起从库里删掉、不可恢复,相关小项的排名与积分会按新排序重算。
启用:正常出现在名单与报名选择里;停用为软删除:历史报名与成绩保留,新报名不再可选该运动员,随时可以再启用;彻底删除:真删除,人员档案连同他名下的报名与成绩一起从库里删掉、不可恢复,相关小项的排名与积分会按新排序重算。表头勾选框会选中当前筛选结果里的全部人员,批量操作用一次请求完成。
</p>
</div>
@@ -375,10 +620,10 @@ async function confirmPurge(): Promise<void> {
<FormField label="工号 / 学号" hint="可留空,用于对账">
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
</FormField>
<FormField label="证件号" hint="证件号与号码布至少填一个,二者都是人员唯一标识">
<FormField label="证件号" hint="可选;不参与认人,但不能与别人重号">
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
</FormField>
<FormField label="号码布" hint="没有身份证信息时可只填号码布">
<FormField label="号码布" hint="认人依据;建议每人一条且全库唯一">
<input v-model="form.bib" class="mx-cell-input" placeholder="例如 101" />
</FormField>
<FormField label="状态" wide>
@@ -387,6 +632,33 @@ async function confirmPurge(): Promise<void> {
</div>
</FormDialog>
<ConfirmDialog
:open="batchDisableOpen"
tone="warning"
title="批量停用选中的运动员?"
:message="`停用 ${batchTargetIds.length} 人后,新报名不再可选他们;历史报名与成绩保留,随时可以再启用。`"
:details="[
'停用是软删除:报名、成绩、积分流水一条不动,榜单不变',
'整批只发一次请求,不会逐个消耗写操作配额',
]"
confirm-text="批量停用"
:pending="batchPending"
@confirm="confirmBatchDisable"
@cancel="batchDisableOpen = false"
/>
<ConfirmDialog
:open="batchPurgeOpen"
tone="danger"
title="批量彻底删除选中的运动员?"
:message="`将把这 ${batchTargetIds.length} 人从人员档案中永久删除,不可恢复。`"
:details="bulkPurgeDetails"
confirm-text="彻底删除"
:pending="batchPending"
@confirm="confirmBatchPurge"
@cancel="batchPurgeOpen = false"
/>
<ConfirmDialog
:open="confirmOpen"
tone="warning"
@@ -427,4 +699,14 @@ async function confirmPurge(): Promise<void> {
padding: 10px 12px;
line-height: 1.5;
}
.bd-batch-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
</style>
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
import type { EventCategory, EventCategoryInput, Gender } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { baseDataApi } from './baseDataApi'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import type { BaseDataTabKey } from './tabs'
@@ -13,6 +14,8 @@ const data = useBaseData()
const toast = useToastStore()
interface CategoryRow {
/** 服务端已有的组别 id;新增的行还没有 id。 */
id: number | null
key: number
name: string
// type="number" 的 v-model 会被 Vue 自动转成 number(空值仍是 ''),这里如实标注
@@ -39,11 +42,33 @@ const confirmOpen = ref(false)
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
const removedNames = computed(() => {
const current = new Set(rows.value.map((row) => row.name.trim()).filter((name) => name !== ''))
return original.value.filter((category) => !current.has(category.name)).map((category) => category.name)
const originalById = computed(() => new Map(original.value.map((category) => [category.id, category])))
/** 还留在表里的已有组别 id;用来判断这次保存有没有「删掉组别」。 */
const keptIds = computed(() => {
const ids = new Set<number>()
for (const row of rows.value) {
if (row.id !== null) ids.add(row.id)
}
return ids
})
/** 本次保存被移除的已有组别(按原始顺序,提示里直接报名字)。 */
const removedCategories = computed(() => original.value.filter((category) => !keptIds.value.has(category.id)))
const removedNames = computed(() => removedCategories.value.map((category) => category.name))
/** 新增的行(还没有服务端 id)。 */
const addedCount = computed(() => rows.value.filter((row) => row.id === null).length)
/**
* 本次保存是否动了组别的「结构」(新增或删除)。
*
* 结构变了只能走整表替换:组别 id 会重建,挂在旧 id 上的报名就断链了,
* 所以服务端在有报名数据时会拒绝。只改字段则走单行就地更新,不受报名数据限制。
*/
const structuralChange = computed(() => addedCount.value > 0 || removedCategories.value.length > 0)
watch(
[() => data.selectedEventId.value, () => data.categoriesOf(data.selectedEventId.value)],
() => {
@@ -57,6 +82,7 @@ watch(
function toRow(category: EventCategory): CategoryRow {
return {
id: category.id,
key: (nextKey += 1),
name: category.name,
multiplier: String(category.multiplier),
@@ -71,6 +97,7 @@ function addRow(): void {
rows.value = [
...rows.value,
{
id: null,
key: (nextKey += 1),
name: '',
multiplier: '1',
@@ -139,15 +166,45 @@ function validate(): boolean {
return Object.keys(next).length === 0
}
function buildInputs(): EventCategoryInput[] {
return rows.value.map((row, index) => ({
/** 一行 → 请求体;sortOrder 为 0 / 空时按行号兜底,与整表替换时的口径保持一致。 */
function rowInput(row: CategoryRow, index: number): EventCategoryInput {
return {
name: row.name.trim(),
multiplier: Number(row.multiplier),
gender: row.gender === '' ? null : row.gender,
ageMin: toAge(row.ageMin),
ageMax: toAge(row.ageMax),
sortOrder: Number(row.sortOrder) || index + 1,
}))
}
}
function buildInputs(): EventCategoryInput[] {
return rows.value.map((row, index) => rowInput(row, index))
}
function categoryDiffers(before: EventCategory, after: EventCategoryInput): boolean {
return (
before.name !== after.name ||
before.multiplier !== after.multiplier ||
(before.gender ?? null) !== after.gender ||
(before.ageMin ?? null) !== after.ageMin ||
(before.ageMax ?? null) !== after.ageMax ||
before.sortOrder !== after.sortOrder
)
}
/** 已有组别里真正被改动过的那些;没改的行不发请求,也少一次写配额。 */
function changedCategories(): { id: number; input: EventCategoryInput }[] {
const byId = originalById.value
const changes: { id: number; input: EventCategoryInput }[] = []
rows.value.forEach((row, index) => {
if (row.id === null) return
const before = byId.get(row.id)
if (before === undefined) return
const input = rowInput(row, index)
if (categoryDiffers(before, input)) changes.push({ id: row.id, input })
})
return changes
}
function requestSave(): void {
@@ -156,7 +213,8 @@ function requestSave(): void {
toast.error('组别校验未通过', '请先修正标红的字段')
return
}
if (removedNames.value.length > 0) {
// 增删组别才需要「整体替换」的二次确认;只改字段是就地更新,直接保存。
if (structuralChange.value && removedCategories.value.length > 0) {
confirmOpen.value = true
return
}
@@ -166,12 +224,24 @@ function requestSave(): void {
async function save(): Promise<void> {
const eventId = data.selectedEventId.value
if (eventId === null) return
const replace = structuralChange.value
saving.value = true
try {
await data.saveCategories(eventId, buildInputs())
toast.success('组别已保存')
if (replace) {
await data.saveCategories(eventId, buildInputs())
toast.success('组别已保存')
} else {
const changes = changedCategories()
for (const change of changes) {
await baseDataApi.events.updateCategory(eventId, change.id, change.input)
}
if (changes.length > 0) await data.loadEventDetail(eventId, true)
toast.success(changes.length === 0 ? '没有需要保存的改动' : `已更新 ${changes.length} 个组别`)
}
confirmOpen.value = false
} catch (caught) {
// 逐条更新可能只成功了前几条:失败后必须把服务端的真实状态拉回来,别让界面停在假象上。
if (!replace) await data.loadEventDetail(eventId, true)
toast.error('保存组别失败', caught instanceof Error ? caught.message : null)
} finally {
saving.value = false
@@ -195,7 +265,7 @@ async function save(): Promise<void> {
:disabled="!data.canWrite.value || selectedEvent === null || saving"
@click="requestSave"
>
{{ saving ? '保存中…' : '保存全部' }}
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
@@ -211,8 +281,9 @@ async function save(): Promise<void> {
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
保存会整体替换该小项的组别配置(PUT /api/events/{{ selectedEvent.id }}/categories)。
若该项目已有报名数据,服务端会拒绝替换。
已有报名数据的小项:改名 / 改年龄 / 改性别 / 改权重会就地保存,报名与成绩一条不动;
其中<strong>改权重会立即按新权重重算该项目已发布成绩的积分与榜单</strong>,改名 / 改年龄 / 改性别不会。
新增或删除组别会被服务端拒绝——组别一重建,挂在它下面的报名就断链了。
</span>
</div>
@@ -287,7 +358,7 @@ async function save(): Promise<void> {
:open="confirmOpen"
tone="danger"
title="保存将移除组别?"
:message="`本次保存会删除 ${removedNames.length} 个组别,并整体替换该小项的组别配置。`"
:message="`本次保存会删除 ${removedCategories.length} 个组别,并整体替换该小项的组别配置。若该项目已有报名数据,服务端会拒绝这次增删——改名 / 改年龄 / 改性别 / 改权重请先撤掉删除操作再保存。`"
:details="removedNames"
confirm-text="确认保存"
:pending="saving"
@@ -438,7 +438,7 @@ async function submitTeam(): Promise<void> {
</div>
<p class="bd-note">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</p>
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
<p class="bd-note">报名会自动带上该运动员的证件号与号码布(认人看号码布);种子等字段仍通过下方报名表导入填写。</p>
<p class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
</template>
</template>
@@ -193,8 +193,10 @@ function downloadTemplate(): void {
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
证件号与号码布都是人员唯一标识:
先按证件号匹配,未命中再按号码布匹配,仍未命中则按「姓名 + 单位」匹配库里证件号为空的人并回填。
号码布是人员的唯一标识:先按号码布匹配;号码布为空或没匹配到,再按「姓名 + 单位」找——
单位内恰好一人且对方还没有号码布就命中并回填;恰好一人但对方已有不同的号码布,按「同名的另一个人」新建档案
(结果里会单独列出,不报错);多人则算歧义,列入冲突逐条决定。证件号是可选档案信息,只写入 / 更新、不参与匹配,
但一个人的证件号不能与库里另一个人相同,撞号会明确报错并指出占用者。
</span>
</div>
@@ -262,11 +264,26 @@ function downloadTemplate(): void {
</div>
<p v-if="result.errors.length === 0 && conflicts.length === 0" class="bd-note">全部行导入成功,没有错误行。</p>
<div v-if="result.sameNameNewPersons.length > 0" class="same-name">
<div class="errors-head">
<span class="mx-badge mx-badge-warning">
以下 {{ result.sameNameNewPersons.length }} 人按同名的另一个人新建了档案(号码布不同)
</span>
<span class="bd-note">这些行已导入成功,仅提醒核对;若号码布填错,请到「运动员」里改回并删除多余档案。</span>
</div>
<ul class="error-list">
<li v-for="(person, index) in result.sameNameNewPersons" :key="index">
{{ person.name }}({{ person.teamCode }}):原有号码布 {{ person.existingBib }} → 新号码布 {{ person.newBib }}
</li>
</ul>
</div>
<section v-if="conflicts.length > 0" class="conflicts">
<div class="conflicts-head">
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
<span class="bd-note">
同一证件号 / 号码布在库里与报名表里的姓名 / 性别 / 年龄 / 单位 / 号码布不一致,本轮未落库,也不建报名。
号码布(或姓名 + 单位)命中了库里已有人员,但姓名 / 性别 / 年龄 / 单位 / 号码布与表里不一致,
本轮未落库,也不建报名。
</span>
</div>
<p class="bd-note">逐条选择保留哪一侧;未决策的冲突会一直留在列表里,不会静默丢弃。</p>
@@ -447,6 +464,9 @@ function downloadTemplate(): void {
margin-bottom: 8px;
flex-wrap: wrap;
}
.same-name {
margin-bottom: 14px;
}
.error-list {
display: flex;
flex-direction: column;
@@ -4,6 +4,7 @@ import type { RecordBonusMode, ScoringProfile, ScoringProfileInput, TieMode } fr
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormField from './FormField.vue'
import { liveOnPublishOf } from '../system/systemApi'
import { parseIndividualToTeamWeight, useBaseData } from './useBaseData'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
@@ -79,6 +80,45 @@ watch(
{ immediate: true },
)
// ── 分批结算方式:全局设置,同样只写 standings 子对象 ────────────────────
const liveOnPublish = ref(true)
const liveSaving = ref(false)
const liveError = ref<string | null>(null)
/** 库里现在的结算方式;settings 仅管理员可见,非管理员拿到 null。 */
const storedLiveOnPublish = computed<boolean | null>(() =>
data.settings.value === null ? null : liveOnPublishOf(data.settings.value),
)
const liveDirty = computed(
() => storedLiveOnPublish.value !== null && liveOnPublish.value !== storedLiveOnPublish.value,
)
// 服务端设置变化(别处保存 / 规则包导入)时回填;本地已改动的勾选不被覆盖。
watch(
() => data.settings.value,
(value) => {
if (value === null) return
if (!liveDirty.value) liveOnPublish.value = liveOnPublishOf(value)
},
{ immediate: true },
)
async function saveLiveOnPublishSetting(): Promise<void> {
liveSaving.value = true
liveError.value = null
try {
await data.saveLiveOnPublish(liveOnPublish.value)
toast.success(
'结算方式已保存',
liveOnPublish.value ? '每发布一批就重算名次与榜单' : '等该组全部录完后才更新名次与榜单',
)
} catch (caught) {
liveError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
liveSaving.value = false
}
}
async function saveWeight(): Promise<void> {
const value = parsedWeight.value
if (value === null) {
@@ -441,6 +481,30 @@ const totalTop = computed(() => {
placeholder="1"
/>
</FormField>
<FormField
label="分批发布时实时更新榜单"
:error="liveError"
hint="开(默认):每发布一批成绩就重算名次、各单位总分与奖牌榜。关掉 = 延迟结算:发布只落成绩,等该项目该组所有批次都录完才一起更新名次与榜单;没录完时可在成绩录入页点「立即结算」。"
>
<div class="settle-row">
<label class="bd-check">
<input
v-model="liveOnPublish"
type="checkbox"
:disabled="!isAdmin || liveSaving"
/>
发布后立即结算
</label>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!isAdmin || !liveDirty || liveSaving"
@click="saveLiveOnPublishSetting"
>
{{ liveSaving ? '保存中…' : '保存结算方式' }}
</button>
</div>
</FormField>
</div>
<p class="bd-note">
这是<strong>全局</strong>设置(standings.individual_to_team_weight),不随某一张积分模板保存,所有模板与所有计分通道共用:
@@ -512,4 +576,10 @@ const totalTop = computed(() => {
.rules-table {
max-width: 420px;
}
.settle-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
</style>
@@ -18,6 +18,7 @@ import ConfirmDialog from '../ConfirmDialog.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { EVENT_ROW_DBLCLICK_TITLE, openEventCategories } from './eventRowActions'
import { useToastStore } from '../../stores/toast'
import type { BaseDataTabKey } from './tabs'
@@ -26,6 +27,14 @@ const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
const data = useBaseData()
const toast = useToastStore()
/** 双击小项条目:先选中它(两次单击不会抢走最终选中),再跳到「组别」分区。 */
function openCategories(eventId: number): void {
openEventCategories(eventId, {
selectEvent: (id) => void data.selectEvent(id),
goTab: (tab) => emit('goTab', tab),
})
}
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,24}$/
const EVENT_TYPE_OPTIONS: Array<{ value: EventType; label: string }> = [
@@ -523,7 +532,9 @@ async function confirmDeleteEvent(): Promise<void> {
:key="event.id"
:class="{ selected: event.id === data.selectedEventId.value }"
class="clickable"
:title="EVENT_ROW_DBLCLICK_TITLE"
@click="data.selectEvent(event.id)"
@dblclick="openCategories(event.id)"
>
<td class="bd-mono">{{ event.code }}</td>
<td>{{ event.name }}</td>
@@ -539,7 +550,7 @@ async function confirmDeleteEvent(): Promise<void> {
<td class="right bd-mono">{{ event.multiplier }}</td>
<td>{{ labelOf(STATUS_OPTIONS, event.status) }}</td>
<td>
<div class="bd-actions">
<div class="bd-actions" @dblclick.stop>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click.stop="openEditEvent(event)">
编辑
</button>
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { ApiError, adminApi, mockMode, mockReason } from '../../api/client'
import type { AthletePurgeResult } from '@/shared/api'
import type { AthleteBatchAction, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
import type {
Athlete,
AthleteInput,
@@ -123,6 +123,18 @@ export const baseDataApi = {
() => adminApi.client.events.saveCategories(eventId, inputs),
() => mockBaseData.saveCategories(eventId, inputs),
),
/**
* 单个组别的就地更新。
*
* 与 saveCategories 一样可以降级到示例数据(现场断网时页面的「示例数据」标记已经说明了一切),
* 但不让 404 降级:404 是「组别已不在」的真实回答,落到示例数据上会把「没改到」显示成「已更新」。
*/
updateCategory: (eventId: number, categoryId: number, input: EventCategoryInput): Promise<EventCategory[]> =>
call(
() => adminApi.client.events.updateCategory(eventId, categoryId, input),
() => mockBaseData.updateCategory(eventId, categoryId, input),
{ fallbackOn404: false },
),
saveChannels: (eventId: number, inputs: ScoreChannelInput[]): Promise<ScoreChannel[]> =>
call(
() => adminApi.client.events.saveChannels(eventId, inputs),
@@ -158,6 +170,16 @@ export const baseDataApi = {
call(() => adminApi.purgeAthlete(id), () => mockBaseData.purgeAthlete(id), {
fallbackOn404: false,
}),
/**
* 批量停用 / 批量彻底删除:一个请求完成整批,逐条成败在响应里。
*
* 与 purge 同理不让 404 降级:404 是「这批人已经被别人处理过」的真实回答,
* 落回示例数据会把「一个都没删掉」显示成「删掉了」。
*/
batch: (action: AthleteBatchAction, ids: number[], dryRun = false): Promise<AthleteBatchResult> =>
call(() => adminApi.client.athletes.batch(action, ids, dryRun), () => mockBaseData.batchAthletes(action, ids, dryRun), {
fallbackOn404: false,
}),
},
entries: {
@@ -317,19 +317,25 @@ function formatFieldValue(value: string | number | null | undefined): string {
}
/**
* 冲突的决策键:证件号优先,没有证件号时用号码布。
* 冲突的决策键:号码布优先,号码布为空再回落证件号,两样都没有时回落姓名 + 单位。
*
* 与服务端 repo_import.go 的 decisionKey() 是同一个约定,两边必须保持一致;
* 只有号码布的行证件号为空串,直接拿证件号当键会让多条冲突互相覆盖。
* 号码布才是人员的唯一标识;证件号自「号码布优先匹配」之后不再参与匹配,
* 同一份表里两个不同的人可以共用一个证件号,拿它当键会让两条冲突共用一行决策
* (面板 :key 重复、勾选互相串)。
*
* 这个键只管面板内的分组与勾选:发给服务端的载荷带的是 idCard + bib 两个字段,
* 服务端按自己的 decisionKey()(repo_import.go)重算,键本身不出现在载荷里。
*/
export function decisionKeyOf(conflict: ImportConflict): string {
return conflict.idCard !== '' ? conflict.idCard : conflict.bib
if (conflict.bib !== '') return conflict.bib
if (conflict.idCard !== '') return conflict.idCard
return `${conflict.incoming.name}·${conflict.incoming.teamCode}`
}
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
export function conflictLabel(conflict: ImportConflict): string {
const key = decisionKeyOf(conflict)
return key === '' ? '(无证件号 / 号码布)' : key
return key === '' ? '(无号码布 / 证件号 / 姓名)' : key
}
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
@@ -0,0 +1,31 @@
/**
* 小项列表「双击条目 → 跳到该小项的组别」的动作(纯函数)。
*
* 抽出来的原因:浏览器在 dblclick 之前必然先派发两次 click,两次都会改选中项,
* 「最终选中的到底是不是被双击的那一项」最容易写错;`.vue` 组件跑不进 node --test,
* 所以把「先选中 → 再跳分区」的顺序与目标放这里断言。
*/
import type { BaseDataTabKey } from './tabs'
/** 行上 title 里的可发现性文案:单击只选中,双击才跳转,不写出来没人会去试。 */
export const EVENT_ROW_DBLCLICK_TITLE = '双击查看该小项的组别'
export interface EventRowActionDeps {
/** 选中该小项(useBaseData.selectEvent)。 */
selectEvent: (id: number) => void
/** 跳转到基础数据页的某个分区。 */
goTab: (tab: BaseDataTabKey) => void
}
/**
* 双击小项条目:先选中这一项,再跳到「组别」分区。
*
* 这里必须自己再选一次:dblclick 之前的两次 click 已经改过选中项(虽然就是同一行,
* 但顺序上不能依赖它),补一次同 id 的选中才能保证跳过去的是被双击的那一项。
* 两次 click 之间不引入任何延时,单击选中既有的即时手感不变。
*/
export function openEventCategories(eventId: number, deps: EventRowActionDeps): void {
deps.selectEvent(eventId)
deps.goTab('categories')
}
+245 -91
View File
@@ -1,5 +1,5 @@
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
import type { AthletePurgeResult } from '@/shared/api'
import type { AthleteBatchAction, AthleteBatchItem, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
import type {
Athlete,
AthleteInput,
@@ -12,6 +12,7 @@ import type {
ImportConflict,
ImportConflictDecision,
ImportConflictField,
SameNameNewPerson,
ScoringProfile,
ScoringProfileInput,
ScoreChannel,
@@ -187,6 +188,37 @@ const IMPORT_COLUMNS = MANDATORY_COLUMNS
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
/**
* 冲突决策键:与服务端一致,证件号优先、为空时取号码布。
*
* 载荷里 idCard 与 bib 两个字段都带,服务端按自己的口径重算键(repo_import.go 的 decisionKey()),
* 所以这里必须与服务端同序,否则决策会落到另一条冲突上。
*/
function importDecisionKey(key: { idCard: string; bib: string }): string {
return key.idCard !== '' ? key.idCard : key.bib
}
/** 报名表里一行的人员信息(姓名已确定非空),供匹配逻辑使用。 */
interface ImportRow {
line: number
name: string
gender: Athlete['gender']
age: number | null
idCard: string
bib: string
team: Team
}
/**
* 人员匹配结果:冲突行不落库、等用户决策;error 是可直接回显的行级报错文案。
* sameNameNewPerson 只在「号码布不同 → 按同名的另一个人新建」时出现,仅作报告。
*/
type AthleteMatch =
| { kind: 'reused' }
| { kind: 'conflict'; conflict: ImportConflict }
| { kind: 'created'; sameNameNewPerson?: SameNameNewPerson }
| { kind: 'error'; message: string }
/** 导出类本身供测试构造隔离实例;页面用下方单例。 */
export class MockBaseData {
private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team }))
@@ -365,7 +397,9 @@ export class MockBaseData {
saveCategories(eventId: number, inputs: EventCategoryInput[]): EventCategory[] {
this.getEvent(eventId)
if ((this.entryCounts.get(eventId) ?? 0) > 0) throw new Error('该项目已有报名数据,不能整体替换组别')
if ((this.entryCounts.get(eventId) ?? 0) > 0) {
throw new Error('该项目已有报名数据,不能新增或删除组别;改名、改年龄、改性别、改权重可以直接保存')
}
const names = new Set<string>()
for (const input of inputs) {
const name = input.name.trim()
@@ -380,6 +414,33 @@ export class MockBaseData {
return this.getEvent(eventId).categories ?? []
}
/**
* 就地更新单个组别:与真实服务端一致,id 不变、只改字段,所以有报名数据也允许改。
*
* 增删组别仍走 saveCategories(示例数据里同样会被报名数据挡住)。
* 真实服务端在权重变化时会重算已发布成绩的积分与榜单,示例数据没有建模流水与榜单,
* 没有可重算的东西——页面照样能拿到更新后的组别列表。
*/
updateCategory(eventId: number, categoryId: number, input: EventCategoryInput): EventCategory[] {
this.getEvent(eventId)
const index = this.categories.findIndex((item) => item.id === categoryId && item.eventId === eventId)
if (index < 0) throw new Error('组别不存在')
const name = input.name.trim()
if (name === '') throw new Error('组别名称必填')
if (this.categories.some((item) => item.eventId === eventId && item.id !== categoryId && item.name === name)) {
throw new Error(`组别名称重复:${name}`)
}
this.categories[index] = {
...input,
name,
// 服务端口径:权重 <= 0 一律按 1 存。
multiplier: input.multiplier > 0 ? input.multiplier : 1,
id: categoryId,
eventId,
}
return this.getEvent(eventId).categories ?? []
}
saveChannels(eventId: number, inputs: ScoreChannelInput[]): ScoreChannel[] {
this.getEvent(eventId)
const combinations = new Set<string>()
@@ -498,6 +559,39 @@ export class MockBaseData {
}
}
/**
* 批量停用 / 批量彻底删除:与真实服务端同形,逐条回报成败。
*
* 不存在的 id 按 `NOT_FOUND` 单条失败,不能整批失败也不能静默跳过;dryRun 只回报不改数据。
* 示例数据没有把报名、成绩与流水建模到具体人员名下(只有每个小项的报名条数),
* 所以彻底删除的统计都是 0、也没有小项需要重算——与单条 purgeAthlete 的口径一致。
*/
batchAthletes(action: AthleteBatchAction, ids: number[], dryRun = false): AthleteBatchResult {
const items: AthleteBatchItem[] = ids.map((id) => {
if (this.athletes.some((athlete) => athlete.id === id)) return { id, ok: true }
return { id, ok: false, code: 'NOT_FOUND', message: '运动员不存在' }
})
if (!dryRun) {
const found = new Set(ids)
this.athletes =
action === 'disable'
? this.athletes.map((athlete) => (found.has(athlete.id) ? { ...athlete, active: false } : athlete))
: this.athletes.filter((athlete) => !found.has(athlete.id))
}
return {
action,
...(dryRun ? { dryRun: true } : {}),
requested: ids.length,
succeeded: items.filter((item) => item.ok).length,
failed: items.filter((item) => !item.ok).length,
items,
purge:
action === 'purge'
? { deletedEntries: 0, deletedResults: 0, deletedLedger: 0, deletedBreaks: 0, recomputedEvents: [] }
: undefined,
}
}
// ── 报名导入 ──────────────────────────────────────────────────────────────
async importEntries(file: File): Promise<EntryImportResult> {
@@ -506,14 +600,11 @@ export class MockBaseData {
/** 冲突决策重跑:没给决策的键仍视为未决策,继续留在返回的 conflicts 里。 */
async resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> {
// 与服务端一致:证件号优先,没有证件号时用号码布当决策键。
const keyOf = (decision: ImportConflictDecision): string =>
decision.idCard !== '' ? decision.idCard : decision.bib
return this.runImport(file, new Map(decisions.map((decision) => [keyOf(decision), decision.action])))
return this.runImport(file, new Map(decisions.map((decision) => [importDecisionKey(decision), decision.action])))
}
private async runImport(file: File, decisions: Map<string, ConflictAction>): Promise<EntryImportResult> {
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [] })
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [], sameNameNewPersons: [] })
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
if (extension !== '.csv') return empty([XLSX_HINT])
const rows = parseCsv(await file.text())
@@ -524,8 +615,7 @@ export class MockBaseData {
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
}
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [] }
const seenIdCards = new Set<string>()
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [], sameNameNewPersons: [] }
const seenBibs = new Set<string>()
rows.slice(1).forEach((row, offset) => {
const line = offset + 2
@@ -578,114 +668,171 @@ export class MockBaseData {
result.errors.push(`第 ${line} 行:有姓名的行必须填证件号或号码布`)
return
}
// 年龄可空;填了就必须是 0–120 的整数,非法行直接跳过,不占证件号 / 号码布名额。
// 年龄可空;填了就必须是 0–120 的整数,非法行直接跳过,不占号码布名额。
const age = parseAge(cell('age'))
if (age !== null && (!Number.isInteger(age) || age < 0 || age > 120)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:年龄必须是 0–120 的整数`)
return
}
if (idCard !== '' && seenIdCards.has(idCard)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:证件号 ${idCard} 在文件中重复出现`)
return
}
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
// 证件号只是可选档案信息,同一份表内重复不再拦截(与服务端口径一致)。
if (bib !== '' && seenBibs.has(bib)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`)
return
}
if (idCard !== '') seenIdCards.add(idCard)
if (bib !== '') seenBibs.add(bib)
const rawGender = cell('gender')
const gender = rawGender === '男' || rawGender === '女' ? rawGender : null
// 契约匹配顺序:证件号 → 号码布 → 姓名 + 单位(库里证件号为空)。
const matched =
(idCard === '' ? undefined : this.athletes.find((athlete) => athlete.idCard === idCard)) ??
(bib === '' ? undefined : this.athletes.find((athlete) => athlete.bib === bib))
if (matched !== undefined) {
const conflict = this.detectConflict(matched, line, idCard, bib, athleteName, gender, age, team)
const key = idCard !== '' ? idCard : bib
if (conflict !== null && decisions.get(key) === undefined) {
result.conflicts.push(conflict)
return
}
if (conflict !== null && decisions.get(key) === 'keep-new') {
matched.name = athleteName
if (gender !== null) matched.gender = gender
if (age !== null) matched.age = age
matched.teamId = team.id
if (idCard !== '') matched.idCard = idCard
if (bib !== '') matched.bib = bib
}
// 库里没有年龄时按表里的值回填,已有年龄一律不动。
if (age !== null && matched.age === null) matched.age = age
result.created += 1
const match = this.matchAthlete({ line, name: athleteName, gender, age, idCard, bib, team }, decisions)
if (match.kind === 'conflict') {
result.conflicts.push(match.conflict)
return
}
// 证件号 / 号码布未命中,但 (姓名, 单位) 命中且库里证件号为空:视为同一人并回填。
const legacy = this.athletes.find(
(athlete) =>
athlete.name === athleteName &&
athlete.teamId === team.id &&
(athlete.idCard === null || athlete.idCard === ''),
)
if (legacy !== undefined) {
const conflict = this.detectConflict(legacy, line, idCard, bib, athleteName, gender, age, team)
const legacyKey = idCard !== '' ? idCard : bib
if (conflict !== null && decisions.get(legacyKey) === undefined) {
result.conflicts.push(conflict)
return
}
if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && bib !== '') legacy.bib = bib
if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && age !== null) legacy.age = age
if (idCard !== '') legacy.idCard = idCard
if (bib !== '' && (legacy.bib === null || legacy.bib === '')) legacy.bib = bib
// 其余情况只补库中为空的年龄,绝不用空值清掉已有值。
if (age !== null && legacy.age === null) legacy.age = age
result.created += 1
if (match.kind === 'error') {
result.skipped += 1
result.errors.push(`第 ${line} 行:${match.message}`)
return
}
this.athletes.push({
id: (this.nextAthleteId += 1),
name: athleteName,
gender,
teamId: team.id,
employeeNo: null,
idCard: idCard === '' ? null : idCard,
bib: bib === '' ? null : bib,
age,
active: true,
})
if (match.kind === 'created' && match.sameNameNewPerson !== undefined) {
result.sameNameNewPersons.push(match.sameNameNewPerson)
}
result.created += 1
})
return result
}
/** name / gender / age / teamCode / bib 与库里不一致才算冲突;表里为空的字段视为未指定。 */
private detectConflict(
athlete: Athlete,
row: number,
idCard: string,
bib: string,
name: string,
gender: Athlete['gender'],
age: number | null,
team: Team,
): ImportConflict | null {
/**
* 人员匹配:号码布精确命中 → 姓名 + 单位 → 新建,与真实服务端 repo_import.go 同序。
*
* 证件号不参与匹配(只是可选档案信息),只在新建 / 回填 / keep-new 时写入;
* 撞到别人的证件号仍然是行级报错,不允许静默改库或静默跳过。
*/
private matchAthlete(row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
if (row.bib !== '') {
const byBib = this.athletes.find((athlete) => athlete.bib === row.bib)
if (byBib !== undefined) return this.reuseOrConflict(byBib, row, decisions)
}
const candidates = this.athletes.filter(
(athlete) => athlete.name === row.name && athlete.teamId === row.team.id,
)
if (candidates.length === 0) return this.createImportedAthlete(row)
const first = candidates[0] as Athlete
if (candidates.length > 1) {
// 同名同单位有多个人:不能替用户挑人,没决策就进面板(与服务端一样以第一条候选为代表)。
// 字段全对得上时也拦:这里认不出「是哪一个人」,猜错就会改错档案。
if (decisions.has(importDecisionKey(row))) return this.reuseOrConflict(first, row, decisions)
return { kind: 'conflict', conflict: this.buildConflict(first, row, this.changedFields(first, row)) }
}
// 表里也给了号码布、且与库里不同 → 同名的另一个人:新建并报告,不拦截导入。
if (first.bib !== null && first.bib !== '' && row.bib !== '' && first.bib !== row.bib) {
const created = this.createImportedAthlete(row)
if (created.kind === 'created') {
created.sameNameNewPerson = {
name: row.name,
teamCode: row.team.code,
newBib: row.bib,
existingBib: first.bib,
}
}
return created
}
return this.reuseOrConflict(first, row, decisions)
}
/** 命中已有人员:无差异直接复用并回填空字段;有差异时按决策落库,没决策就进冲突面板。 */
private reuseOrConflict(ex: Athlete, row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
const conflict = this.conflictFor(ex, row)
if (conflict === null) {
const taken = this.backfillAthlete(ex, row)
return taken === null ? { kind: 'reused' } : { kind: 'error', message: taken }
}
const action = decisions.get(importDecisionKey(conflict))
if (action === undefined) return { kind: 'conflict', conflict }
// keep-existing:人员字段保持库里的值;keep-new:用表里的字段覆盖(表里没填的保留)。
if (action === 'keep-new') {
const taken = this.idCardTakenError(row.idCard, ex.id)
if (taken !== null) return { kind: 'error', message: taken }
ex.name = row.name
if (row.gender !== null) ex.gender = row.gender
if (row.age !== null) ex.age = row.age
ex.teamId = row.team.id
if (row.idCard !== '') ex.idCard = row.idCard
if (row.bib !== '') ex.bib = row.bib
}
return { kind: 'reused' }
}
/** 只补库中为空的证件号 / 号码布 / 年龄,已有值不动;证件号撞别人时回报行级错误。 */
private backfillAthlete(ex: Athlete, row: ImportRow): string | null {
if (row.idCard !== '' && (ex.idCard === null || ex.idCard === '')) {
const taken = this.idCardTakenError(row.idCard, ex.id)
if (taken !== null) return taken
ex.idCard = row.idCard
}
if (row.bib !== '' && (ex.bib === null || ex.bib === '')) ex.bib = row.bib
if (row.age !== null && ex.age === null) ex.age = row.age
return null
}
/** 新建人员档案;证件号撞到别人时只回报错误,不落库。 */
private createImportedAthlete(row: ImportRow): AthleteMatch {
const taken = this.idCardTakenError(row.idCard, 0)
if (taken !== null) return { kind: 'error', message: taken }
this.athletes.push({
id: (this.nextAthleteId += 1),
name: row.name,
gender: row.gender,
teamId: row.team.id,
employeeNo: null,
idCard: row.idCard === '' ? null : row.idCard,
bib: row.bib === '' ? null : row.bib,
age: row.age,
active: true,
})
return { kind: 'created' }
}
/**
* 证件号不参与匹配,但仍一人一号:撞到别人时报出占用者是谁。
* 文案与服务端 idCardTakenError 一致,现场在降级模式与真实服务端看到的是同一句话。
*/
private idCardTakenError(idCard: string, excludeId: number): string | null {
if (idCard === '') return null
const taken = this.athletes.find((athlete) => athlete.idCard === idCard && athlete.id !== excludeId)
if (taken === undefined) return null
const bib = taken.bib === null || taken.bib === '' ? '未填' : taken.bib
return `证件号 ${idCard} 已被另一个人占用(姓名 ${taken.name},号码布 ${bib})`
}
/** 把「库中人员 + 表里这一行」包成冲突对象,没有差异时返回 null。 */
private conflictFor(athlete: Athlete, row: ImportRow): ImportConflict | null {
const changed = this.changedFields(athlete, row)
return changed.length === 0 ? null : this.buildConflict(athlete, row, changed)
}
/** name / gender / age / teamCode / bib 与库里不一致才算差异;表里为空的字段视为未指定。 */
private changedFields(athlete: Athlete, row: ImportRow): ImportConflictField[] {
const changed: ImportConflictField[] = []
if (athlete.name !== name) changed.push('name')
if (gender !== null && athlete.gender !== gender) changed.push('gender')
if (athlete.name !== row.name) changed.push('name')
if (row.gender !== null && athlete.gender !== row.gender) changed.push('gender')
// 年龄两侧都有值且不同才算差异;旧数据没有年龄时不能把所有人标成冲突。
if (age !== null && athlete.age !== null && athlete.age !== age) changed.push('age')
if (athlete.teamId !== team.id) changed.push('teamCode')
if (row.age !== null && athlete.age !== null && athlete.age !== row.age) changed.push('age')
if (athlete.teamId !== row.team.id) changed.push('teamCode')
// 号码布两侧都给了且不一致才算差异;表里没给视为不修改。
if (bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== bib) changed.push('bib')
if (changed.length === 0) return null
if (row.bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== row.bib) {
changed.push('bib')
}
return changed
}
/** 冲突对象;changed 为空也算冲突(同名同单位认不出是哪一个,同样要进面板等决策)。 */
private buildConflict(athlete: Athlete, row: ImportRow, changed: ImportConflictField[]): ImportConflict {
return {
row,
idCard,
bib,
row: row.line,
idCard: row.idCard,
bib: row.bib,
athleteId: athlete.id,
existing: {
name: athlete.name,
@@ -695,7 +842,14 @@ export class MockBaseData {
employeeNo: athlete.employeeNo,
bib: athlete.bib,
},
incoming: { name, gender, age, teamCode: team.code, employeeNo: null, bib: bib === '' ? null : bib },
incoming: {
name: row.name,
gender: row.gender,
age: row.age,
teamCode: row.team.code,
employeeNo: null,
bib: row.bib === '' ? null : row.bib,
},
changedFields: changed,
}
}
@@ -287,6 +287,23 @@ async function saveIndividualToTeamWeight(weight: number): Promise<EventSettings
return saved
}
/**
* 保存「分批发布时是否实时更新榜单」(standings.live_on_publish,服务端字段 liveOnPublish)。
*
* 与折算系数一样只提交完整的 standings 子对象:漏传的字段会被服务端保持原值,
* 但整体回传能保证前端拿到的设置与服务端口径一致。shared 的 EventSettings 还没带这个字段,
* 所以这里按扩展字段拼装;回写 event store 后开关与录入页提示立刻同步。
*/
async function saveLiveOnPublish(live: boolean): Promise<EventSettings> {
const eventStore = useEventStore()
const current = eventStore.settings
if (current === null) throw new Error('赛事设置未加载,无法保存结算方式')
const standings = { ...current.standings, liveOnPublish: live }
const saved = await adminApi.client.settings.update({ standings })
eventStore.settings = saved
return saved
}
export interface BaseDataController {
teams: Ref<Team[]>
sports: Ref<Sport[]>
@@ -329,6 +346,8 @@ export interface BaseDataController {
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
/** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */
saveIndividualToTeamWeight: (weight: number) => Promise<EventSettings>
/** 只改「分批发布是否实时更新榜单」;保存后同步 event store。 */
saveLiveOnPublish: (live: boolean) => Promise<EventSettings>
}
export function useBaseData(): BaseDataController {
@@ -377,5 +396,6 @@ export function useBaseData(): BaseDataController {
importEntries,
resolveImportConflicts,
saveIndividualToTeamWeight,
saveLiveOnPublish,
}
}
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import MxIcon from '../MxIcon.vue'
import { describeError, sessionSettingsApi } from './systemApi'
import {
SESSION_HOURS_FALLBACK,
describeSessionLifetime,
formatSessionExpiry,
validateSessionHours,
type SessionHoursBounds,
} from './sessionSettings'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
/**
* 登录有效期(GET/PUT /api/system/session)。
*
* 会话寿命是机器级配置:它决定这台机器上所有人的登录能待多久,
* 所以放在「系统设置」里,而不是某个赛事的信息页。
* admin-only(服务端两个接口都限 admin),非 admin 不挂载本面板。
*/
const auth = useAuthStore()
const toast = useToastStore()
const input = ref<string>('')
const saved = ref<number | null>(null)
const bounds = ref<SessionHoursBounds>({
minHours: SESSION_HOURS_FALLBACK.minHours,
maxHours: SESSION_HOURS_FALLBACK.maxHours,
})
const loading = ref(true)
const saving = ref(false)
const loadError = ref<string | null>(null)
const saveError = ref<string | null>(null)
/** 当前会话的到期时间优先取 /auth/me(登录时与心跳时都会刷新),拿不到再退回本接口下发的值。 */
const expiresAt = computed(() => auth.sessionExpiresAt ?? remoteExpiresAt.value)
const remoteExpiresAt = ref<string | null>(null)
const expiryText = computed(() => formatSessionExpiry(expiresAt.value))
const check = computed(() => validateSessionHours(input.value, bounds.value))
const checkOk = computed(() => check.value.ok)
const checkMessage = computed(() => (check.value.ok ? null : check.value.message))
const candidateHours = computed(() => (check.value.ok ? check.value.hours : null))
const dirty = computed(() => candidateHours.value !== null && candidateHours.value !== saved.value)
const hint = computed(() =>
candidateHours.value === null
? '登录后能待多久:有效期内继续操作会自动顺延,不用中途重新登录。'
: describeSessionLifetime(candidateHours.value),
)
onMounted(() => {
void load()
})
async function load(): Promise<void> {
loading.value = true
try {
const settings = await sessionSettingsApi.load()
bounds.value = { minHours: settings.minHours, maxHours: settings.maxHours }
saved.value = settings.sessionHours
input.value = String(settings.sessionHours)
remoteExpiresAt.value = settings.expiresAt
loadError.value = null
} catch (caught) {
loadError.value = describeError(caught)
input.value = input.value === '' ? String(SESSION_HOURS_FALLBACK.defaultHours) : input.value
} finally {
loading.value = false
}
}
async function save(): Promise<void> {
const hours = candidateHours.value
if (hours === null) {
saveError.value = checkMessage.value
return
}
saving.value = true
saveError.value = null
try {
const settings = await sessionSettingsApi.save(hours)
saved.value = settings.sessionHours
input.value = String(settings.sessionHours)
remoteExpiresAt.value = settings.expiresAt
toast.success('登录有效期已保存', '对之后的新登录生效,已登录的会话不受影响')
} catch (caught) {
saveError.value = describeError(caught)
} finally {
saving.value = false
}
}
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">登录有效期</span>
<div class="head-actions">
<span v-if="dirty" class="mx-badge mx-badge-warning">未保存</span>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="loading || saving || !checkOk || !dirty"
@click="save"
>
<MxIcon name="check" :size="14" /> {{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<div class="mx-card-body">
<p class="notice">{{ expiryText }}</p>
<p v-if="loadError !== null" class="notice error">读取失败:{{ loadError }}</p>
<p v-if="saveError !== null" class="notice error">{{ saveError }}</p>
<div class="form-grid">
<div class="field">
<label class="mx-label" for="session-hours">登录有效期(小时)</label>
<div class="mx-input-wrap">
<input
id="session-hours"
v-model="input"
class="mx-input"
type="number"
:min="bounds.minHours"
:max="bounds.maxHours"
step="1"
:disabled="loading || saving"
/>
</div>
<span class="field-hint">
范围 {{ bounds.minHours }}~{{ bounds.maxHours }} 小时(最长 30 天),默认
{{ SESSION_HOURS_FALLBACK.defaultHours }} 小时。
</span>
</div>
<div class="field">
<span class="mx-label">说明</span>
<p class="hint-text" :class="{ invalid: !checkOk }">{{ hint }}</p>
<p v-if="checkMessage !== null" class="field-hint error">{{ checkMessage }}</p>
</div>
</div>
<p class="notice">
只对之后的新登录生效:已经登录的会话保持各自的到期时间,不会被踢下线;
有效期内继续操作会自动顺延到期时间,因此从早用到晚不会中途掉线。
</p>
</div>
</section>
</template>
<style scoped>
.head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mx-card-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
}
.field-hint {
margin-top: 6px;
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
.field-hint.error {
color: var(--mx-error);
}
.hint-text {
margin-top: 8px;
font-size: 12px;
color: var(--mx-t2);
line-height: 1.7;
}
.hint-text.invalid {
color: var(--mx-error);
}
.notice {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
}
.notice.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
@media (max-width: 1023px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,69 @@
import type { CurrentUser } from '@/shared'
/**
* 登录有效期(系统级设置)的纯函数:范围校验与到期时间显示。
*
* 抽成不依赖 Vue 的模块,是为了能在 node --test 下直接测到边界(0、721、小数、空串),
* 而不必挂载组件;面板与用户菜单共用同一份格式化逻辑,避免两处显示不一致。
*/
/** 服务端 /api/system/session 未就绪时的兜底范围,与 auth.MinSessionHours / MaxSessionHours 一致。 */
export const SESSION_HOURS_FALLBACK = { minHours: 1, maxHours: 720, defaultHours: 24 } as const
export interface SessionHoursBounds {
minHours: number
maxHours: number
}
export type SessionHoursCheck = { ok: true; hours: number } | { ok: false; message: string }
/** 校验面板输入的登录有效期(整数小时)。服务端仍会独立校验一遍,这里只是为了当场给出中文提示。 */
export function validateSessionHours(raw: string | number, bounds: SessionHoursBounds): SessionHoursCheck {
const text = typeof raw === 'number' ? String(raw) : raw.trim()
if (text === '') return { ok: false, message: '请填写登录有效期(小时)' }
if (!/^\d+$/.test(text)) return { ok: false, message: '登录有效期只能是整数小时' }
const hours = Number(text)
if (hours < bounds.minHours || hours > bounds.maxHours) {
return { ok: false, message: `登录有效期需在 ${bounds.minHours}~${bounds.maxHours} 小时之间` }
}
return { ok: true, hours }
}
/** 把小时数说成人话,现场一眼能看出「这是登录后能待多久」。 */
export function describeSessionLifetime(hours: number): string {
if (!Number.isFinite(hours) || hours <= 0) return '登录有效期未设置'
const days = hours / 24
const dayText = Number.isInteger(days) ? `${days} 天` : `${days.toFixed(1)} 天`
return `登录后最长可保持 ${hours} 小时(${dayText})不用重新登录;期间只要继续操作,到期时间会自动顺延。`
}
/**
* 从 /auth/me 的响应里取当前会话的到期时间。
*
* CurrentUser 类型定义在 shared/types(本轮不改共享包),字段是附加下发的,
* 所以这里按可选字段读,值不合法时一律当「没有」处理。
*/
export function readSessionExpiresAt(
user: (CurrentUser & { sessionExpiresAt?: unknown }) | null | undefined,
): string | null {
const raw = user?.sessionExpiresAt
if (typeof raw !== 'string' || raw.trim() === '') return null
return Number.isNaN(new Date(raw).getTime()) ? null : raw
}
function pad(value: number): string {
return String(value).padStart(2, '0')
}
/**
* 把服务端下发的 RFC3339(UTC)到期时间渲染成本机时区的「当前会话将于 2026-09-12 08:31 过期」。
* 服务端只给带时区的时间串,换算成本机时间由前端做——现场看的是自己电脑上的钟。
*/
export function formatSessionExpiry(iso: string | null | undefined): string {
if (iso === null || iso === undefined || iso === '') return '当前会话的到期时间未知'
const at = new Date(iso)
if (Number.isNaN(at.getTime())) return '当前会话的到期时间未知'
const text = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ` +
`${pad(at.getHours())}:${pad(at.getMinutes())}`
return `当前会话将于 ${text} 过期`
}
@@ -879,6 +879,18 @@ export const rulesApi = {
export type EventSettingsPatch = Partial<EventSettings>
/**
* 分批结算开关的取值。
*
* shared/types 的 EventSettings 还没收敛这个字段(改共享类型会影响大屏),
* 所以在管理端按可选字段读取:旧服务端 / 示例数据不带它时按「实时更新」处理,
* 与 Go 侧 ParseEventSettings 的默认值一致。
*/
export function liveOnPublishOf(settings: EventSettings | null | undefined): boolean {
const value = (settings?.standings as { liveOnPublish?: unknown } | undefined)?.liveOnPublish
return typeof value === 'boolean' ? value : true
}
export const settingsApi = {
async save(patch: EventSettingsPatch): Promise<MockAware<EventSettings>> {
return withMockInfo(
@@ -894,6 +906,104 @@ export const settingsApi = {
},
}
// ---------------------------------------------------------------------------
// 分批结算(GET /api/results/settlement、POST /api/results/settle)
// ---------------------------------------------------------------------------
/** 未结算的 (小项, 组别):延迟结算下已发布成绩还没进榜单的那一组。 */
export interface SettlementGroup {
eventId: number
categoryId: number
categoryName: string
/** 该出成绩的报名项数(弃权 / 失格不计入)。 */
required: number
/** 其中已经有可发布成绩的条数。 */
published: number
}
export interface SettlementStatus {
/** false = 延迟结算:发布只落成绩,等整组录完才更新榜单。 */
liveOnPublish: boolean
groups: SettlementGroup[]
}
function readSettlementStatus(raw: unknown): SettlementStatus {
if (!isRecord(raw)) throw new ApiError('PARSE_FAILED', '结算状态格式不符', 0, raw)
const groups = Array.isArray(raw.groups) ? raw.groups : []
return {
liveOnPublish: raw.liveOnPublish !== false,
groups: groups.filter(isRecord).map((item) => ({
eventId: readNumber(item, 'eventId') ?? 0,
categoryId: readNumber(item, 'categoryId') ?? 0,
categoryName: readString(item, 'categoryName') ?? '',
required: readNumber(item, 'required') ?? 0,
published: readNumber(item, 'published') ?? 0,
})),
}
}
export const settlementApi = {
/** 查未结算清单;这是提示用的辅助信息,读失败由调用方决定是否忽略。 */
async status(eventId: number, categoryId?: number | null): Promise<SettlementStatus> {
const body = await requestJson<unknown>('/results/settlement', {
query: { eventId, categoryId: categoryId ?? undefined },
})
return readSettlementStatus(body)
},
/** 立即结算:按当前已发布成绩重算积分流水并刷新榜单(写操作不降级)。 */
async settle(eventId: number, categoryId?: number | null): Promise<void> {
await requestJson<unknown>('/results/settle', {
method: 'POST',
json: { eventId, categoryId: categoryId ?? null },
})
},
}
// ---------------------------------------------------------------------------
// 登录有效期(系统级设置:GET/PUT /api/system/session)
// ---------------------------------------------------------------------------
export interface SessionSettings {
sessionHours: number
defaultSessionHours: number
minHours: number
maxHours: number
/** 当前会话的到期时间(RFC3339 UTC);未登录为 null。 */
expiresAt: string | null
}
/**
* 登录有效期存在系统库(system.db),不是赛事级 /api/settings:
* 换一个赛事项目,这台机器上所有人的会话寿命不该跟着变。
*
* 读写都不降级示例数据:假的「已保存」会让管理员以为改完就生效了。
*/
export const sessionSettingsApi = {
async load(): Promise<SessionSettings> {
return toSessionSettings(await requestJson<unknown>('/system/session'))
},
async save(hours: number): Promise<SessionSettings> {
return toSessionSettings(
await requestJson<unknown>('/system/session', { method: 'PUT', json: { sessionHours: hours } }),
)
},
}
function toSessionSettings(value: unknown): SessionSettings {
if (!isRecord(value)) throw new ApiError('PARSE_FAILED', '登录有效期响应格式不符', 0, value)
const sessionHours = readNumber(value, 'sessionHours')
if (sessionHours === null) throw new ApiError('PARSE_FAILED', '登录有效期响应缺少 sessionHours', 0, value)
return {
sessionHours,
defaultSessionHours: readNumber(value, 'defaultSessionHours') ?? sessionHours,
minHours: readNumber(value, 'minHours') ?? 1,
maxHours: readNumber(value, 'maxHours') ?? 720,
expiresAt: readString(value, 'expiresAt'),
}
}
// ---------------------------------------------------------------------------
// 字体(内置字体 + 上传字体)
// ---------------------------------------------------------------------------