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

@@ -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,