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:
1 parent
9d4ee0946c
commit
5ae49b001c
18 files changed
+767
-13
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user