feat: 运动员彻底删除(真物理删除 + 重算积分)+ 报名表 status 容错 + 部署包带导入规范

## 运动员「彻底删除」= 真删除
`DELETE /api/athletes/{id}?purge=1`(不带参数仍是「停用」软删除)。在一个事务里:
删积分流水(含已作废历史流水)→ 删破纪录登记 → 删成绩 → 删报名 → 删人员 →
对受影响的 (小项, 组别) 调 recomputeLedgerTx + applyComputedRanks 重算流水与名次 →
bumpEvent/bumpStandings 推大屏 → 审计 athlete.purge(含删除规模)。
响应带 {deletedEntries, deletedResults, deletedLedger, deletedBreaks, recomputedEvents}。
必须先删 results 再删 entries(results.entry_id 无 ON DELETE);流水按 athlete_id / entry_id /
result_id 三种引用一把清,漏一种就留悬挂。

管理端「运动员」行加「彻底删除」按钮 + 危险确认框(如实写明报名与成绩一并删除、
相关小项排名与积分会重算),成功 toast 报出删除规模。

## 报名表 status 列容错
新增 entry_status.go 的 normalizeImportStatus:大小写不敏感(DNS/Registered 都认),
认「正常/退赛/弃赛/请假/取消资格/犯规/未出场/未到/缺席」等中文别名,留空即 registered,
认不出则给中文行级错误并跳过该行。此前原样入库,撞 entries.status 的 CHECK 约束后
抛原始 SQLite 错误,填表人看不懂。

## 修 isUniqueViolation 把 CHECK 失败误报成「已存在」
SQLite 的 CHECK / NOT NULL 失败也带 "constraint failed",旧匹配把「小项 status 取值非法」
报成 409「小项编码已存在」,现场会去找一个根本不存在的重名编码。收紧为只认
"unique constraint failed"。实测:重复编码仍 409;非法 status 不再误报。

## 安装包平铺《报名名单导入规范》
deploy/prepare-extra.mjs 改清单式,部署包根目录除 使用说明.txt 外新增
报名名单导入规范.txt。

测试:服务端 12 包全绿(新增 athlete_purge_test.go、entry_status_test.go);
前端 vue-tsc 通过、166 例全过;Console 语法检查 17 文件。
另在真二进制 + 真 SQLite 上跑通破坏性路径:删人后本人痕迹全为 0、他人不受影响、
榜单按新排序重算、foreign_key_check 无悬挂。docs/ 与 devdocs/ 同步更新。
This commit is contained in:
AskaEth committed 2026-09-10 22:37:25 +08:00
1 parent 22ab49e73a
commit 9d4ee0946c
22 files changed
+1264 -54

No files matched your search

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Athlete, AthleteInput, Gender } from '@/shared'
import type { AthletePurgeResult } from '@/shared/api'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormDialog from './FormDialog.vue'
@@ -40,6 +41,12 @@ const confirmOpen = ref(false)
const confirmTarget = ref<Athlete | null>(null)
const confirmPending = ref(false)
const purgeOpen = ref(false)
const purgeTarget = ref<Athlete | null>(null)
const purgePending = ref(false)
/** 删除失败时的说明留在页面上:服务端那句原文(人员已不在等)比一行「操作失败」有用得多。 */
const purgeError = ref<string | null>(null)
function emptyForm(): AthleteForm {
return { name: '', gender: '', age: '', teamId: '', employeeNo: '', idCard: '', bib: '', active: true }
}
@@ -174,6 +181,49 @@ async function toggleActive(athlete: Athlete): Promise<void> {
confirmPending.value = false
}
}
function askPurge(athlete: Athlete): void {
purgeTarget.value = athlete
purgeError.value = null
purgeOpen.value = true
}
/**
* 删除结果的提示语。
*
* 名次与积分会随成绩一起变,所以别只说「已删除」:把删了多少条、重算了哪些小项说清楚,
* 操作员才不会回头发现榜单变了却找不到原因。
*/
function purgeSummary(name: string, result: AthletePurgeResult): string {
const parts = [`报名 ${result.deletedEntries} 条`, `成绩 ${result.deletedResults} 条`]
// 流水与纪录不是每次都有,为 0 时报出来只会让人以为删漏了什么。
if (result.deletedLedger > 0) parts.push(`积分流水 ${result.deletedLedger} 条`)
if (result.deletedBreaks > 0) parts.push(`纪录 ${result.deletedBreaks} 条`)
const recomputed = result.recomputedEvents.length
const tail = recomputed > 0 ? `,已重算 ${recomputed} 个小项的积分与名次` : ''
return `已彻底删除「${name}」:${parts.join('、')}${tail}`
}
async function confirmPurge(): Promise<void> {
const target = purgeTarget.value
// 双保险:确认框在请求期间禁用按钮,这里再挡一次重复点击。
if (target === null || purgePending.value) return
purgePending.value = true
purgeError.value = null
try {
const result = await data.purgeAthlete(target.id)
toast.success(purgeSummary(target.name, result))
purgeOpen.value = false
} catch (caught) {
// 服务端的 message(人员已不在、网络错误…)必须原文展示,不能压成「删除失败」。
const message = caught instanceof Error ? caught.message : '彻底删除失败'
purgeError.value = message
toast.error('彻底删除失败', message)
purgeOpen.value = false
} finally {
purgePending.value = false
}
}
</script>
<template>
@@ -207,6 +257,10 @@ async function toggleActive(athlete: Athlete): Promise<void> {
</div>
</div>
<div v-if="purgeError !== null" class="mx-badge mx-badge-error purge-error">
<MxIcon name="warning" :size="14" /> {{ purgeError }}
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
@@ -259,6 +313,14 @@ async function toggleActive(athlete: Athlete): Promise<void> {
>
{{ athlete.active ? '停用' : '启用' }}
</button>
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || purgePending"
@click="askPurge(athlete)"
>
{{ purgePending && purgeTarget?.id === athlete.id ? '删除中…' : '彻底删除' }}
</button>
</div>
</td>
</tr>
@@ -274,7 +336,9 @@ async function toggleActive(athlete: Athlete): Promise<void> {
</tbody>
</table>
</div>
<p class="bd-note">停用为软删除:历史报名与成绩保留,新报名不再可选该运动员。</p>
<p class="bd-note">
启用:正常出现在名单与报名选择里;停用为软删除:历史报名与成绩保留,新报名不再可选该运动员,随时可以再启用;彻底删除:真删除,人员档案连同他名下的报名与成绩一起从库里删掉、不可恢复,相关小项的排名与积分会按新排序重算。
</p>
</div>
<FormDialog
@@ -333,6 +397,21 @@ async function toggleActive(athlete: Athlete): Promise<void> {
@confirm="confirmTarget && toggleActive(confirmTarget)"
@cancel="confirmOpen = false"
/>
<ConfirmDialog
:open="purgeOpen"
tone="danger"
title="彻底删除该运动员?"
:message="`将把「${purgeTarget?.name ?? ''}」从人员档案中永久删除,不可恢复。`"
:details="[
'他名下的报名与成绩会一并删除,相关小项的排名与积分会重新计算',
'已经有成绩的比赛,删人会让名次和积分发生变化;只是想暂时不让他报名,请用「停用」',
]"
confirm-text="彻底删除"
:pending="purgePending"
@confirm="confirmPurge"
@cancel="purgeOpen = false"
/>
</section>
</template>
@@ -340,4 +419,12 @@ async function toggleActive(athlete: Athlete): Promise<void> {
.danger-text {
color: var(--mx-red);
}
.purge-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin-bottom: 14px;
padding: 10px 12px;
line-height: 1.5;
}
</style>
@@ -1,5 +1,6 @@
import { ref } from 'vue'
import { ApiError, adminApi, mockMode, mockReason } from '../../api/client'
import type { AthletePurgeResult } from '@/shared/api'
import type {
Athlete,
AthleteInput,
@@ -41,12 +42,29 @@ const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
*/
export const baseDataFallback = ref(false)
function isFallback(error: unknown): boolean {
// 404 表示接口在当前服务端尚未实现,与整个后端没起来一样按「未就绪」处理。
return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404)
interface CallOptions {
/**
* 404 是否也算「接口未实现」而降级到示例数据;默认 true。
*
* 按 ID 的删除必须传 false:那里的 404 是「这条记录已经不在了」这种真实回答,
* 降级成示例数据会把「根本没删掉」显示成「删除成功」。
*/
fallbackOn404?: boolean
}
async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
function isFallback(error: unknown, fallbackOn404: boolean): boolean {
if (!(error instanceof ApiError)) return false
if (FALLBACK_CODES.includes(error.code)) return true
// 404 表示接口在当前服务端尚未实现,与整个后端没起来一样按「未就绪」处理;
// 但按 ID 删除时 404 也可能是「这条记录已经不在了」,那种调用方会传 fallbackOn404: false。
return fallbackOn404 && error.status === 404
}
async function call<T>(
real: () => Promise<T>,
mock: () => T | Promise<T>,
options: CallOptions = {},
): Promise<T> {
try {
const value = await real()
baseDataFallback.value = false
@@ -56,7 +74,7 @@ async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Prom
}
return value
} catch (error) {
if (!isFallback(error)) throw error
if (!isFallback(error, options.fallbackOn404 ?? true)) throw error
baseDataFallback.value = true
mockMode.value = true
mockReason.value = error instanceof Error ? error.message : '无法连接服务端'
@@ -121,6 +139,17 @@ export const baseDataApi = {
call(() => adminApi.client.athletes.update(id, input), () => mockBaseData.updateAthlete(id, input)),
remove: (id: number): Promise<void> =>
call(() => adminApi.client.athletes.remove(id), () => mockBaseData.removeAthlete(id)),
/**
* 彻底删除:服务端物理删除该人员及其报名、成绩并重算积分,返回删除规模统计。
*
* 与同组的 remove 不同,这里故意不让 404 降级:404 是「人员已不在」的真实回答,
* 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」,现场就出事了。
* 后端整体连不上(网络 / 超时)时仍照常降级,示例数据里可以照删。
*/
purge: (id: number): Promise<AthletePurgeResult> =>
call(() => adminApi.purgeAthlete(id), () => mockBaseData.purgeAthlete(id), {
fallbackOn404: false,
}),
},
entries: {
@@ -1,4 +1,5 @@
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
import type { AthletePurgeResult } from '@/shared/api'
import type {
Athlete,
AthleteInput,
@@ -465,6 +466,26 @@ export class MockBaseData {
this.athletes.splice(index, 1)
}
/**
* 彻底删除:与真实服务端一致,人员、报名、成绩一起消失(这里就是把人从内存名单里移掉)。
*
* 示例数据的报名只是每个小项上的一个条数、成绩与积分流水更是没有建模,
* 没有归属到具体人员的东西可删,所以统计各项都是 0、也没有小项需要重算;
* 页面照样能拿到完整的统计结构,不会是 undefined。
*/
purgeAthlete(id: number): AthletePurgeResult {
const index = this.athletes.findIndex((athlete) => athlete.id === id)
if (index < 0) throw new Error(`运动员 ${id} 不存在`)
this.athletes.splice(index, 1)
return {
deletedEntries: 0,
deletedResults: 0,
deletedLedger: 0,
deletedBreaks: 0,
recomputedEvents: [],
}
}
// ── 报名导入 ──────────────────────────────────────────────────────────────
async importEntries(file: File): Promise<EntryImportResult> {
@@ -19,6 +19,7 @@ import type {
Team,
TeamInput,
} from '@/shared'
import type { AthletePurgeResult } from '@/shared/api'
import { adminApi, mockMode, mockReason } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import { useEventStore } from '../../stores/event'
@@ -233,6 +234,13 @@ async function removeAthlete(id: number): Promise<void> {
await reloadAthletes()
}
/** 彻底删除:重新拉取名单,删除规模统计回给页面做提示;失败(人员已不在、网络错误)原样抛出。 */
async function purgeAthlete(id: number): Promise<AthletePurgeResult> {
const result = await baseDataApi.athletes.purge(id)
await reloadAthletes()
return result
}
function importEntries(file: File): Promise<EntryImportResult> {
return baseDataApi.entries.importFile(file)
}
@@ -307,6 +315,8 @@ export interface BaseDataController {
saveProfile: (input: ScoringProfileInput, id: number | null) => Promise<ScoringProfile>
saveAthlete: (input: AthleteInput, id: number | null) => Promise<Athlete>
removeAthlete: (id: number) => Promise<void>
/** 返回删除规模统计(报名 / 成绩 / 流水条数、重算过的小项),页面据此提示「榜为什么变了」。 */
purgeAthlete: (id: number) => Promise<AthletePurgeResult>
importEntries: (file: File) => Promise<EntryImportResult>
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
/** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */
@@ -354,6 +364,7 @@ export function useBaseData(): BaseDataController {
saveProfile,
saveAthlete,
removeAthlete,
purgeAthlete,
importEntries,
resolveImportConflicts,
saveIndividualToTeamWeight,