feat: 参赛单位「彻底删除」= 假删除(界面隐藏,数据与榜单全保留)

与运动员的真物理删除是**两套相反口径**,有意如此(两个按钮同名,代码与
文档里都做了显著区分):

- 运动员 `?purge=1`:真删人员+报名+成绩+流水并重算积分(上一版 `9d4ee09`)
- 参赛单位 `?purge=1`:只置 `teams.deleted_at` + `active = 0`,让单位从列表里
  消失;报名、成绩、积分流水、人工调整、纪录、名下运动员全部原样,
  **不触发重算、积分榜不变**(单位下挂着几十号人,真删代价太大)

实现:
- 迁移 0018 `ALTER TABLE teams ADD COLUMN deleted_at TEXT`
- `listTeams` 默认排除已隐藏单位;`?includeDeleted=1` 仅 admin;
  `GET /api/teams/{id}` 对已隐藏单位:admin 200 / 非 admin 404
- 审计 `team.purge`(detail 标明是隐藏);成功走 `writeVoid`(无统计对象)
- 报名导入按 `team_code` 命中已隐藏单位时复活(清 deleted_at、置 active=1)
- 故意不过滤的地方都加了注释:榜单补单位名、写路径与审计、导入按 code 查、
  赛事包导出、大屏快照、按 team_id JOIN 取历史单位名——过滤会让历史成绩的
  单位名变空白或让包的引用悬挂

管理端「参赛单位」行加「彻底删除」按钮 + 危险确认框(如实写明"从列表移除、
数据不受影响、积分榜不会变化"),失败原文展示、404 不降级。

顺带修一处界面文案与行为不符:单位页原写「停用后新报名不再可选该单位」,
但报名选择器其实仍会列出已停用单位供手动勾选(只有「全选」跳过它们),
已按真实行为改写(两处)。

测试:服务端 12 包全绿(新增 team_purge_test.go 与 0018 迁移用例);
前端 172 例全过(新增 team-purge-api.test.ts 6 例)。docs/ 与 devdocs/ 同步。
This commit is contained in:
AskaEth committed 2026-09-10 23:48:28 +08:00
1 parent 9d4ee0946c
commit 5ae49b001c
18 files changed
+767 -13

No files matched your search

+6
View File
@@ -164,6 +164,12 @@ export const adminApi = {
purgeAthlete: (id: number): Promise<AthletePurgeResult> =>
callWrite(() => client.athletes.purge(id)),
/**
* 彻底删除参赛单位:与运动员不同,这是「假删除」——单位从列表里消失,但它的报名、成绩与
* 积分流水一行不动,也不会触发任何重算。空响应,没有统计。失败原样抛出(含 404)。
*/
purgeTeam: (id: number): Promise<void> => callWrite(() => client.teams.purge(id)),
records: (): Promise<RecordEntry[]> =>
call(() => client.records.list(), () => mockDb.records()),
@@ -41,6 +41,12 @@ const confirmOpen = ref(false)
const confirmTarget = ref<Team | null>(null)
const confirmPending = ref(false)
const purgeOpen = ref(false)
const purgeTarget = ref<Team | null>(null)
const purgePending = ref(false)
/** 删除失败时的说明留在页面上:服务端那句原文(单位已不在等)比一行「操作失败」有用得多。 */
const purgeError = ref<string | null>(null)
function emptyForm(): TeamForm {
return { code: '', name: '', shortName: '', logoPath: '', color: '#3482FF', sortOrder: '0', active: true }
}
@@ -159,6 +165,33 @@ async function toggleActive(team: Team): Promise<void> {
confirmPending.value = false
}
}
function askPurge(team: Team): void {
purgeTarget.value = team
purgeError.value = null
purgeOpen.value = true
}
async function confirmPurge(): Promise<void> {
const target = purgeTarget.value
// 双保险:确认框在请求期间禁用按钮,这里再挡一次重复点击。
if (target === null || purgePending.value) return
purgePending.value = true
purgeError.value = null
try {
await data.purgeTeam(target.id)
toast.success(`已彻底删除「${target.name}」`)
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>
@@ -188,6 +221,10 @@ async function toggleActive(team: Team): 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>
@@ -239,6 +276,14 @@ async function toggleActive(team: Team): Promise<void> {
>
{{ team.active ? '停用' : '启用' }}
</button>
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || purgePending"
@click="askPurge(team)"
>
{{ purgePending && purgeTarget?.id === team.id ? '删除中…' : '彻底删除' }}
</button>
</div>
</td>
</tr>
@@ -254,7 +299,9 @@ async function toggleActive(team: Team): Promise<void> {
</tbody>
</table>
</div>
<p class="bd-note">停用为软删除:历史成绩与积分流水保留,新报名不再可选该单位。</p>
<p class="bd-note">
启用:正常出现在单位列表与报名选择里;停用为软删除:历史成绩与积分流水保留,单位仍在列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它,随时可以再启用;彻底删除:把该单位从列表中移除,界面上不可恢复,但它的报名、成绩与积分数据全部保留,积分榜不会变化。
</p>
</div>
<FormDialog
@@ -301,12 +348,27 @@ async function toggleActive(team: Team): Promise<void> {
:open="confirmOpen"
tone="warning"
title="停用该参赛单位?"
:message="`停用「${confirmTarget?.name ?? ''}」后,新报名不再可选该单位;历史成绩与积分流水保留,可随时重新启用。`"
:message="`停用「${confirmTarget?.name ?? ''}」后,它仍会出现在单位列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它;历史成绩与积分流水保留,可随时重新启用。`"
confirm-text="停用"
:pending="confirmPending"
@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>
@@ -319,4 +381,12 @@ async function toggleActive(team: Team): 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>
@@ -89,6 +89,14 @@ export const baseDataApi = {
call(() => adminApi.client.teams.create(input), () => mockBaseData.createTeam(input)),
update: (id: number, input: TeamInput): Promise<Team> =>
call(() => adminApi.client.teams.update(id, input), () => mockBaseData.updateTeam(id, input)),
/**
* 彻底删除(假删除):单位从列表里消失,数据全部保留,失败原样抛出即可。
*
* 与 athletes.purge 同理,这里不让 404 降级:404 是「单位已不在」的真实回答,
* 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」。网络 / 超时仍照常降级。
*/
purge: (id: number): Promise<void> =>
call(() => adminApi.purgeTeam(id), () => mockBaseData.purgeTeam(id), { fallbackOn404: false }),
},
sports: {
@@ -277,6 +277,18 @@ export class MockBaseData {
return { ...team }
}
/**
* 彻底删除(假删除):与真实服务端一致,只让这个单位从名单里消失。
*
* 服务端是打 `deleted_at` 标记(报名、成绩、积分流水一行未动),示例数据没有也不需要有这一列——
* 从内存名单里移掉,界面看到的结果就一样了。没有统计对象,和 athletes 的 purge 不同。
*/
purgeTeam(id: number): void {
const index = this.teams.findIndex((team) => team.id === id)
if (index < 0) throw new Error(`单位 ${id} 不存在`)
this.teams.splice(index, 1)
}
// ── 大项 ──────────────────────────────────────────────────────────────────
listSports(): Sport[] {
@@ -169,6 +169,12 @@ async function saveTeam(input: TeamInput, id: number | null): Promise<Team> {
return saved
}
/** 彻底删除(假删除):重新拉单位列表,被标成不可见的单位自然就不再出现;失败(单位已不在、网络错误)原样抛出。 */
async function purgeTeam(id: number): Promise<void> {
await baseDataApi.teams.purge(id)
await reloadTeams()
}
async function saveSport(input: SportInput, id: number | null): Promise<Sport> {
const saved = id === null ? await baseDataApi.sports.create(input) : await baseDataApi.sports.update(id, input)
await reloadSports()
@@ -306,6 +312,8 @@ export interface BaseDataController {
teamById: (id: number | null) => Team | undefined
sportById: (id: number | null) => Sport | undefined
saveTeam: (input: TeamInput, id: number | null) => Promise<Team>
/** 假删除:单位从列表里消失,数据全部保留,积分榜不变。 */
purgeTeam: (id: number) => Promise<void>
saveSport: (input: SportInput, id: number | null) => Promise<Sport>
removeSport: (id: number) => Promise<void>
saveEvent: (input: EventInput, id: number | null) => Promise<Event>
@@ -355,6 +363,7 @@ export function useBaseData(): BaseDataController {
teamById,
sportById,
saveTeam,
purgeTeam,
saveSport,
removeSport,
saveEvent,
+132
View File
@@ -0,0 +1,132 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 参赛单位「彻底删除」的前端契约:方法 / 路径 + `?purge=1`,以及失败时服务端那句话的传递。
*
* 口径与运动员不同:这是假删除(单位从列表里消失,报名、成绩、积分流水一行未动、积分榜不变),
* 所以响应是空响应、没有统计对象——这里一并钉住,免得有人照运动员那套抄。
*/
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/teams/{id}?purge=1', async () => {
respondWith(() => jsonResponse({ ok: true, data: null }))
await adminApi.purgeTeam(3)
assert.equal(lastRequest.url, '/api/teams/3?purge=1')
assert.equal(lastRequest.method, 'DELETE')
})
// 服务端中间件要求已登录的写请求带 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.purgeTeam(3)
} 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.purgeTeam(3),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 500)
assert.equal(error.code, 'INTERNAL')
assert.equal(error.message, '服务端异常')
return true
},
)
})
// 404 是「单位已经被别人删掉」这种真实回答,不是「接口未实现」:
// 一旦降级到示例数据,页面会把「根本没删掉」提示成「已彻底删除」。
test('purge 收到 404 时不降级,错误原样抛给页面', async () => {
baseDataFallback.value = false
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '参赛单位不存在' } }, 404),
)
await assert.rejects(
() => baseDataApi.teams.purge(3),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 404)
assert.equal(error.message, '参赛单位不存在')
return true
},
)
assert.equal(baseDataFallback.value, false, '不应切到示例数据')
})
// 对照组:同组其它接口的 404 仍按「接口未实现」降级到示例数据,只有 purge 不降级。
// (baseDataApi.teams 没暴露软删除 remove——页面上的「停用」走 update,所以拿 list 作对照。)
test('teams 其它接口的 404 仍降级到示例数据(既有行为未受影响)', async () => {
baseDataFallback.value = false
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '接口不存在' } }, 404),
)
const teams = await baseDataApi.teams.list()
assert.ok(teams.length > 0, '应拿到示例数据的单位')
assert.equal(baseDataFallback.value, true)
})
// 后端整体连不上时仍降级到示例数据;假删除没有统计对象,示例数据也不能凭空造一个出来。
// 用 id=2 避开上一条已经删掉的 1(示例数据是模块级单例)。
test('后端不可用降级示例数据时,假删除照常生效且不返回统计', async () => {
baseDataFallback.value = false
respondWith(() => {
throw new TypeError('fetch failed')
})
const result = await baseDataApi.teams.purge(2)
assert.equal(result, undefined)
assert.equal(baseDataFallback.value, true)
})
+21 -2
View File
@@ -166,6 +166,17 @@ export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
purge(id: number): Promise<AthletePurgeResult>
}
export interface TeamsApi extends CrudApi<Team, TeamInput> {
/**
* 彻底删除(`DELETE /api/teams/{id}?purge=1`)。
*
* 注意口径与 AthletesApi.purge 不同:这里是「假删除」——只让该单位从默认列表里消失,
* 报名、成绩、积分流水与人工调整一行未动,积分榜不变,也不会有任何重算。
* 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。
*/
purge(id: number): Promise<void>
}
export interface EntriesApi {
/** 报名表按文件导入,后端解析 xlsx。 */
importFile(file: File): Promise<EntryImportResult>
@@ -303,6 +314,14 @@ function readPurgeResult(value: unknown): AthletePurgeResult {
}
}
function createTeamsApi(http: Http): TeamsApi {
return {
...createCrudApi<Team, TeamInput>(http, '/teams'),
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;和运动员不同,这里是空响应。
purge: (id) => http.requestVoid(`/teams/${id}`, { method: 'DELETE', query: { purge: 1 } }),
}
}
function createAthletesApi(http: Http): AthletesApi {
return {
...createCrudApi<Athlete, AthleteInput>(http, '/athletes'),
@@ -443,7 +462,7 @@ function defaultRequestId(): string {
export class ApiClient {
readonly auth: AuthApi
readonly settings: SettingsApi
readonly teams: CrudApi<Team, TeamInput>
readonly teams: TeamsApi
readonly sports: CrudApi<Sport, SportInput>
readonly events: EventsApi
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
@@ -508,7 +527,7 @@ export class ApiClient {
update: (patch) => http.request<EventSettings>('/settings', { method: 'PUT', body: patch }),
}
this.teams = createCrudApi<Team, TeamInput>(http, '/teams')
this.teams = createTeamsApi(http)
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
this.events = createEventsApi(http)
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')