Files
Scoreboard/web/src/admin/api/client.ts
T
AskaEth 5ae49b001c 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/ 同步。
2026-09-10 23:48:28 +08:00

215 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ref } from 'vue'
import { ApiError, createApiClient } from '@/shared/api'
import type { AthletePurgeResult } from '@/shared/api'
import type {
AthleteStanding,
AuditLog,
CurrentUser,
DisplayCommand,
EntryWithResult,
Event,
EventSettings,
HealthInfo,
Page,
PageQuery,
PointAdjustment,
PublishPayload,
RecordEntry,
Result,
SaveDraftPayload,
ScoringProfile,
Sport,
StandingRow,
Team,
TeamBreakdown,
} from '@/shared'
import { mockDb } from './mock'
/**
* 管理端数据入口。
*
* 后端未就绪(连接失败 / 超时)时自动降级到内存示例数据,并把 `mockMode` 置为 true,
* 界面据此显示「示例数据」标记。
* 业务错误(404、鉴权失败、校验失败、冲突)与写操作一律不降级——那些是真实服务端的回答。
*/
// 只有「根本连不上服务端」才降级到示例数据;404 / 解析失败是服务端给出的真实回答,
// 必须原样抛出,否则「用户不存在」「接口不存在」会被伪装成「后端未就绪」。
const FALLBACK_CODES = ['NETWORK', 'TIMEOUT']
export const mockMode = ref(false)
export const mockReason = ref<string | null>(null)
let unauthorizedHandler: (() => void) | null = null
export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler
}
const client = createApiClient({
onUnauthorized: () => unauthorizedHandler?.(),
})
function isFallback(error: unknown): boolean {
return error instanceof ApiError && FALLBACK_CODES.includes(error.code)
}
/** 与 call 同构,但把「本次是否降级」一并返回,供页面显示局部示例数据标记。 */
async function callWithMock<T>(
real: () => Promise<T>,
mock: () => T | Promise<T>,
): Promise<{ data: T; mock: boolean }> {
try {
const data = await real()
if (mockMode.value) {
mockMode.value = false
mockReason.value = null
}
return { data, mock: false }
} catch (error) {
if (!isFallback(error)) throw error
mockMode.value = true
mockReason.value = error instanceof Error ? error.message : '无法连接服务端'
return { data: await mock(), mock: true }
}
}
async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
return (await callWithMock(real, mock)).data
}
/** 写操作一律不降级:示例数据里的「保存成功」会让操作员以为成绩已经落库。 */
async function callWrite<T>(real: () => Promise<T>): Promise<T> {
return real()
}
export interface Bootstrap {
/** settings 是 admin-only;访客/录入员读不到时为 null,其余赛事数据照常返回。 */
settings: EventSettings | null
teams: Team[]
sports: Sport[]
events: Event[]
profiles: ScoringProfile[]
standings: StandingRow[]
athleteStandings: AthleteStanding[]
}
/** 除 settings 外都视为必需:失败时交给 call 决定降级到示例数据还是抛出业务错误。 */
function required<T>(result: PromiseSettledResult<T>): T {
if (result.status === 'fulfilled') return result.value
throw result.reason
}
async function bootstrap(): Promise<Bootstrap> {
return call(
async () => {
const [settings, teams, sports, events, profiles, standings, athleteStandings] =
await Promise.allSettled([
client.settings.get(),
client.teams.list(),
client.sports.list(),
client.events.list(),
client.scoringProfiles.list(),
client.standings.teams(),
client.standings.athletes(),
] as const)
// GET /api/settings 是 admin-only,guest/recorder 必然 403。
// 这是权限边界而非服务不可用,不能让整页数据跟着失败,所以 settings 单独降级为 null。
return {
settings: settings.status === 'fulfilled' ? settings.value : null,
teams: required(teams),
sports: required(sports),
events: required(events),
profiles: required(profiles),
standings: required(standings),
athleteStandings: required(athleteStandings),
}
},
() => mockDb.bootstrap(),
)
}
export const adminApi = {
client,
bootstrap,
eventDetail: (id: number): Promise<Event> =>
call(() => client.events.get(id), () => mockDb.eventDetail(id)),
entries: (eventId: number): Promise<EntryWithResult[]> =>
call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)),
saveDraft: (payload: SaveDraftPayload): Promise<Result[]> =>
callWrite(() => client.results.saveDraft(payload)),
publish: (payload: PublishPayload): Promise<Result[]> =>
callWrite(() => client.results.publish(payload)),
unpublish: (payload: PublishPayload): Promise<Result[]> =>
callWrite(() => client.results.unpublish(payload)),
standings: (): Promise<StandingRow[]> =>
call(() => client.standings.teams(), () => mockDb.standings()),
athleteStandings: (): Promise<AthleteStanding[]> =>
call(() => client.standings.athletes(), () => mockDb.athleteStandings()),
breakdown: (teamId: number): Promise<TeamBreakdown> =>
call(() => client.standings.breakdown(teamId), () => mockDb.breakdown(teamId)),
/**
* 彻底删除运动员:服务端物理删除人员、报名与成绩,并重算受影响小项的积分与名次,
* 返回删除规模统计供页面提示。失败原样抛出:人员不存在(404)或网络错误都要让操作员看到那句话。
*/
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()),
adjustments: (query: PageQuery = {}): Promise<Page<PointAdjustment>> =>
call(() => client.adjustments.list(query), () => mockDb.adjustments()),
auditLogs: (query: PageQuery = {}): Promise<Page<AuditLog>> =>
call(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)),
/** 审计日志需要知道「这一批是不是示例数据」,用局部标记而不是读全局 mockMode。 */
auditLogsWithMock: (query: PageQuery = {}): Promise<{ data: Page<AuditLog>; mock: boolean }> =>
callWithMock(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)),
displayCommand: (command: DisplayCommand): Promise<void> =>
callWrite(() => client.display.command(command)),
login: (username: string, password: string): Promise<CurrentUser> =>
callWrite(() => client.auth.login(username, password)),
logout: (): Promise<void> => callWrite(() => client.auth.logout()),
me: (): Promise<CurrentUser> =>
call(
() => client.auth.me(),
() => {
// 示例数据模式下没有真实会话,统一按「未登录」处理。
throw new ApiError('AUTH_REQUIRED', '示例数据模式下无会话', 0)
},
),
changePassword: (currentPassword: string, newPassword: string): Promise<{ relogin: boolean }> =>
callWrite(() => client.auth.changePassword(currentPassword, newPassword)),
health: (): Promise<HealthInfo> =>
call(
() => client.health(),
() => mockDb.health(),
),
}
export type AdminApi = typeof adminApi
export { ApiError }