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,5 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { ApiError, createApiClient } from '@/shared/api'
|
||||
import type { AthletePurgeResult } from '@/shared/api'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
AuditLog,
|
||||
@@ -156,6 +157,13 @@ export const adminApi = {
|
||||
breakdown: (teamId: number): Promise<TeamBreakdown> =>
|
||||
call(() => client.standings.breakdown(teamId), () => mockDb.breakdown(teamId)),
|
||||
|
||||
/**
|
||||
* 彻底删除运动员:服务端物理删除人员、报名与成绩,并重算受影响小项的积分与名次,
|
||||
* 返回删除规模统计供页面提示。失败原样抛出:人员不存在(404)或网络错误都要让操作员看到那句话。
|
||||
*/
|
||||
purgeAthlete: (id: number): Promise<AthletePurgeResult> =>
|
||||
callWrite(() => client.athletes.purge(id)),
|
||||
|
||||
records: (): Promise<RecordEntry[]> =>
|
||||
call(() => client.records.list(), () => mockDb.records()),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
/**
|
||||
* 运动员「彻底删除」的前端契约:方法 / 路径 + `?purge=1`,以及失败时服务端那句话的传递。
|
||||
*
|
||||
* 服务端的原文(例如 404「运动员不存在」)一旦被换成「操作失败」现场就只能靠猜;
|
||||
* 而 404 又是「人员已不在」的真实回答,绝不能降级成示例数据假装删掉了。
|
||||
*/
|
||||
|
||||
interface Captured {
|
||||
url?: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
|
||||
let lastRequest: Captured = {}
|
||||
|
||||
// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例,
|
||||
// 之后再替换 globalThis.fetch 对它无效。所以这里装一个固定函数,每个用例只换回复。
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
lastRequest = {
|
||||
url: String(input),
|
||||
method: init?.method ?? 'GET',
|
||||
headers: (init?.headers ?? {}) as Record<string, string>,
|
||||
}
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
function respondWith(response: () => Response): void {
|
||||
lastRequest = {}
|
||||
reply = response
|
||||
}
|
||||
|
||||
const { ApiError, adminApi } = await import('../api/client')
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
|
||||
test('彻底删除走 DELETE /api/athletes/{id}?purge=1', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: null }))
|
||||
|
||||
await adminApi.purgeAthlete(7)
|
||||
|
||||
assert.equal(lastRequest.url, '/api/athletes/7?purge=1')
|
||||
assert.equal(lastRequest.method, 'DELETE')
|
||||
})
|
||||
|
||||
// 字段名与服务端 athletePurge 一致;页面提示语要靠它说清「删了什么、重算了哪些小项」。
|
||||
test('成功响应带统计时,客户端拿到删除规模与重算小项', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: { deletedEntries: 1, deletedResults: 1, deletedLedger: 2, deletedBreaks: 0, recomputedEvents: [1] },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await adminApi.purgeAthlete(7)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
deletedEntries: 1,
|
||||
deletedResults: 1,
|
||||
deletedLedger: 2,
|
||||
deletedBreaks: 0,
|
||||
recomputedEvents: [1],
|
||||
})
|
||||
})
|
||||
|
||||
// 删除在服务端已经提交、不可回滚:统计缺失时也只能按 0 报,不能冒出 undefined 或报成失败。
|
||||
test('服务端没带统计时按 0 / 空数组兜底', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: null }))
|
||||
|
||||
const result = await adminApi.purgeAthlete(7)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
deletedEntries: 0,
|
||||
deletedResults: 0,
|
||||
deletedLedger: 0,
|
||||
deletedBreaks: 0,
|
||||
recomputedEvents: [],
|
||||
})
|
||||
})
|
||||
|
||||
// 服务端中间件要求已登录的写请求带 X-CSRF-Token,漏掉就是 403,删不掉还看不出原因。
|
||||
test('彻底删除带上 CSRF 双提交 Cookie 的请求头', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: null }))
|
||||
const globalWithDocument = globalThis as { document?: unknown }
|
||||
globalWithDocument.document = { cookie: 'csrf_token=token-123' }
|
||||
try {
|
||||
await adminApi.purgeAthlete(1)
|
||||
} finally {
|
||||
delete globalWithDocument.document
|
||||
}
|
||||
|
||||
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'token-123')
|
||||
})
|
||||
|
||||
test('失败时不吞错误:服务端那句原文原样带出', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'INTERNAL', message: '服务端异常' } }, 500),
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
() => adminApi.purgeAthlete(7),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.status, 500)
|
||||
assert.equal(error.code, 'INTERNAL')
|
||||
assert.equal(error.message, '服务端异常')
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// 页面调用的是 baseDataApi 这一层:权限 / 服务端错误都是真实回答,不能降级成示例数据假装删掉了。
|
||||
test('基础数据层把服务端错误原样抛给页面,不降级成示例数据', async () => {
|
||||
baseDataFallback.value = false
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'FORBIDDEN', message: '需要管理员权限' } }, 403),
|
||||
)
|
||||
|
||||
await assert.rejects(() => baseDataApi.athletes.purge(7), (error: Error) =>
|
||||
error.message.includes('需要管理员权限'),
|
||||
)
|
||||
assert.equal(baseDataFallback.value, false, '不应切到示例数据')
|
||||
})
|
||||
|
||||
// 404 是「人员已经被别人删掉」这种真实回答,不是「接口未实现」:
|
||||
// 一旦降级到示例数据,页面会把「根本没删掉」提示成「已彻底删除」。
|
||||
test('purge 收到 404 时不降级,错误原样抛给页面', async () => {
|
||||
baseDataFallback.value = false
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '运动员不存在' } }, 404),
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
() => baseDataApi.athletes.purge(7),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.status, 404)
|
||||
assert.equal(error.message, '运动员不存在')
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(baseDataFallback.value, false, '不应切到示例数据')
|
||||
})
|
||||
|
||||
// 对照组:同组的软删除 remove 行为不变,404 仍按「接口未实现」降级到示例数据。
|
||||
// 示例数据里 id=1001 存在,能删掉就说明确实走了 mock 分支,而不是把 404 抛了出来。
|
||||
test('remove 的 404 仍降级到示例数据(既有行为未受影响)', async () => {
|
||||
baseDataFallback.value = false
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '运动员不存在' } }, 404),
|
||||
)
|
||||
|
||||
await baseDataApi.athletes.remove(1001)
|
||||
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
})
|
||||
|
||||
// 后端整体连不上时仍降级到示例数据;示例数据也返回完整的统计结构,页面不会拿到 undefined。
|
||||
// (示例数据里没有归属到人员的报名与成绩,所以各项是 0,用 id=1002 避开上一条已删掉的 1001。)
|
||||
test('后端不可用降级示例数据时,统计结构依然完整', async () => {
|
||||
baseDataFallback.value = false
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
|
||||
const result = await baseDataApi.athletes.purge(1002)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
deletedEntries: 0,
|
||||
deletedResults: 0,
|
||||
deletedLedger: 0,
|
||||
deletedBreaks: 0,
|
||||
recomputedEvents: [],
|
||||
})
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
})
|
||||
+62
-2
@@ -139,6 +139,33 @@ export interface EventsApi extends CrudApi<Event, EventInput> {
|
||||
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底删除的规模统计(服务端 `athletePurge`),同时进审计日志。
|
||||
*
|
||||
* 名次与积分会随成绩消失而变化,所以 `recomputedEvents` 是给操作员看的重点:
|
||||
* 删掉一个人之后,哪些小项的榜单被重算了。
|
||||
*/
|
||||
export interface AthletePurgeResult {
|
||||
deletedEntries: number
|
||||
deletedResults: number
|
||||
deletedLedger: number
|
||||
deletedBreaks: number
|
||||
/** 因成绩删除而重算了积分与名次的小项 ID。 */
|
||||
recomputedEvents: number[]
|
||||
}
|
||||
|
||||
export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
|
||||
/**
|
||||
* 彻底删除(`DELETE /api/athletes/{id}?purge=1`)。
|
||||
*
|
||||
* 与 `remove` 的软删除(停用,`active = 0`,数据都在)不同,这是物理删除:
|
||||
* 服务端在同一事务里删掉该人员的人员行、报名、成绩与相关积分流水,
|
||||
* 并按新排序重算受影响小项的积分与名次(别人的名次会变),随后推送大屏刷新。
|
||||
* 人员已被别人删掉时回 404。
|
||||
*/
|
||||
purge(id: number): Promise<AthletePurgeResult>
|
||||
}
|
||||
|
||||
export interface EntriesApi {
|
||||
/** 报名表按文件导入,后端解析 xlsx。 */
|
||||
importFile(file: File): Promise<EntryImportResult>
|
||||
@@ -252,6 +279,39 @@ function createCrudApi<T, Input>(http: Http, base: string): CrudApi<T, Input> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读删除统计。
|
||||
*
|
||||
* 统计缺失时按 0 / 空数组处理:删除在服务端已经提交、不可回滚,这时提示里冒出 undefined
|
||||
* 或直接报「删除失败」都是在骗操作员。老服务端不认 ?purge=1、data 为 null 时也会走到这里。
|
||||
*/
|
||||
function readPurgeResult(value: unknown): AthletePurgeResult {
|
||||
const source = isRecord(value) ? value : {}
|
||||
const count = (key: string): number => {
|
||||
const raw = source[key]
|
||||
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
|
||||
}
|
||||
const events = source['recomputedEvents']
|
||||
return {
|
||||
deletedEntries: count('deletedEntries'),
|
||||
deletedResults: count('deletedResults'),
|
||||
deletedLedger: count('deletedLedger'),
|
||||
deletedBreaks: count('deletedBreaks'),
|
||||
recomputedEvents: Array.isArray(events)
|
||||
? events.filter((item): item is number => typeof item === 'number')
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
function createAthletesApi(http: Http): AthletesApi {
|
||||
return {
|
||||
...createCrudApi<Athlete, AthleteInput>(http, '/athletes'),
|
||||
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。
|
||||
purge: async (id) =>
|
||||
readPurgeResult(await http.request<unknown>(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })),
|
||||
}
|
||||
}
|
||||
|
||||
function createEventsApi(http: Http): EventsApi {
|
||||
return {
|
||||
...createCrudApi<Event, EventInput>(http, '/events'),
|
||||
@@ -387,7 +447,7 @@ export class ApiClient {
|
||||
readonly sports: CrudApi<Sport, SportInput>
|
||||
readonly events: EventsApi
|
||||
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
|
||||
readonly athletes: CrudApi<Athlete, AthleteInput>
|
||||
readonly athletes: AthletesApi
|
||||
readonly entries: EntriesApi
|
||||
readonly results: ResultsApi
|
||||
readonly standings: StandingsApi
|
||||
@@ -452,7 +512,7 @@ export class ApiClient {
|
||||
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
|
||||
this.events = createEventsApi(http)
|
||||
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
|
||||
this.athletes = createCrudApi<Athlete, AthleteInput>(http, '/athletes')
|
||||
this.athletes = createAthletesApi(http)
|
||||
|
||||
this.entries = {
|
||||
importFile: (file) => {
|
||||
|
||||
Reference in new issue
Block a user