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:
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,
|
||||
|
||||
Reference in new issue
Block a user