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

+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')