与运动员的真物理删除是**两套相反口径**,有意如此(两个按钮同名,代码与
文档里都做了显著区分):
- 运动员 `?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/ 同步。
215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
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 }
|