import { ApiError } from '@/shared' import type { ApiErrorCode, EntryStatus } from '@/shared' /** * 报名项批量操作(退赛 / 恢复 / 移出报名)的前端逻辑。 * * 网络层直接 fetch 而不是走 adminApi:client.ts 的接口表与 shared/api.ts 归口在共享层, * 本功能只服务录入页,先把请求构造与确认文案收在这一个模块里,便于单测; * CSRF 与 X-Request-Id 的补法与 shared/api.ts 的写请求保持一致,否则写操作会被 403 挡回。 */ /** 与 server/internal/httpapi/entries_batch.go 的 maxEntryBatch 一致。 */ export const MAX_ENTRY_BATCH = 500 export const ENTRY_BATCH_PATH = '/api/entries/batch' /** 确认框里逐条列出「将被删的人」时最多列几行,超出的只报数。 */ const MAX_LISTED_PEOPLE = 20 export type EntryBatchAction = 'scratch' | 'restore' | 'remove' export interface EntryBatchPayload { action: EntryBatchAction ids: number[] reason?: string requestId?: string } export interface EntryBatchItem { id: number ok: boolean error?: string skipped?: boolean note?: string } export interface EntryBatchSummary { ok: number failed: number skipped: number changed: number removedEntries: number removedResults: number removedLedger: number removedBreaks: number recomputedEvents: number[] } export interface EntryBatchResult { items: EntryBatchItem[] summary: EntryBatchSummary } /** 批量操作作用于的勾选行;字段只取确认文案与跳过判定需要的部分。 */ export interface EntryBatchRow { entryId: number /** 展示名:运动员姓名 → 队员首人 → 单位(与表格行文案同口径)。 */ name: string bib: string | null team: string categoryId: number entryStatus: EntryStatus } export interface EntryBatchPlan { action: EntryBatchAction /** 去重后的报名项 id(服务端同样会去重,这里先去掉重复勾选)。 */ ids: number[] /** 真正会被改动的行数。 */ changed: number /** 会被跳过的行及其原因(dq / dns 是别人的判定,不覆盖)。 */ skipped: { name: string; note: string }[] /** 会重算的组别数量:重算按 (小项, 组别) 走,本页只有当前小项。 */ scopeCount: number message: string details: string[] } /** * 去重并丢掉非法 id(0 / 负数 / 非整数),保持勾选顺序。 * 与服务端 dedupeIDs 同一口径:重复勾选不该变成两条请求明细。 */ export function dedupeEntryIds(ids: number[]): number[] { const out: number[] = [] const seen = new Set() for (const id of ids) { if (!Number.isInteger(id) || id <= 0 || seen.has(id)) continue seen.add(id) out.push(id) } return out } /** 批量条数校验;通过返回 null,否则返回可直接上屏的中文提示。 */ export function entryBatchSizeError(count: number): string | null { if (count === 0) return '请先勾选要操作的报名项' if (count > MAX_ENTRY_BATCH) { return `一次最多处理 ${MAX_ENTRY_BATCH} 条报名项,请分批提交` } return null } /** 该行在当前动作下会不会被改动;不改动时返回跳过原因(与服务端文案一致)。 */ export function entryBatchSkipNote(action: EntryBatchAction, status: EntryStatus): string | null { if (action === 'remove') return null if (action === 'scratch' && status === 'registered') return null if (action === 'restore' && status === 'scratched') return null switch (status) { case 'scratched': return '已是退赛状态,跳过' case 'registered': return '当前正常参赛,无需恢复' case 'dq': return '已被判罚取消资格(dq),跳过,不覆盖原判定' case 'dns': return '已标记未出场(dns),跳过,不覆盖原判定' } return `当前状态为「${status}」,跳过` } /** 行在确认框里的身份行:姓名|号码布|单位。 */ function personLine(row: EntryBatchRow): string { return `${row.name || '(未署名)'}|号码 ${row.bib ?? '—'}|${row.team || '未知单位'}` } /** * 把一次批量操作整理成确认框能直接用的内容: * 前端先算清「动谁、跳过谁、重算几个组别」,不让操作员点完才知道。 */ export function planEntryBatch(action: EntryBatchAction, rows: EntryBatchRow[]): EntryBatchPlan { const ids = dedupeEntryIds(rows.map((row) => row.entryId)) // 重复勾选不该在「动几个人」里数两遍:按 entryId 去重后再统计。 const seen = new Set() const unique = rows.filter((row) => { if (seen.has(row.entryId)) return false seen.add(row.entryId) return true }) const scopes = new Set(unique.map((row) => row.categoryId)) const changedRows: EntryBatchRow[] = [] const skipped: { name: string; note: string }[] = [] for (const row of unique) { const note = entryBatchSkipNote(action, row.entryStatus) if (note === null) changedRows.push(row) else skipped.push({ name: row.name || '(未署名)', note }) } const changed = changedRows.length const scopeCount = scopes.size const details: string[] = [] if (action === 'remove') { details.push(`将删除以下 ${changed} 人(物理删除,不可恢复):`) for (const row of changedRows.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${personLine(row)}`) if (changedRows.length > MAX_LISTED_PEOPLE) { details.push(`…还有 ${changedRows.length - MAX_LISTED_PEOPLE} 人未列出`) } } else if (skipped.length > 0) { details.push(`以下 ${skipped.length} 人会被跳过(不覆盖已有判定):`) for (const item of skipped.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${item.name}:${item.note}`) if (skipped.length > MAX_LISTED_PEOPLE) { details.push(`…还有 ${skipped.length - MAX_LISTED_PEOPLE} 人未列出`) } } let message = '' if (action === 'scratch') { message = `将把选中的 ${changed} 人标记为退赛(0 分且不占名次),随后按新名次重算积分榜。退赛随时可以用「批量恢复」撤回。` } else if (action === 'restore') { message = `将把选中的 ${changed} 人恢复为正常参赛,随后按新名次重算积分榜。` } else { message = `将从报名名单中物理删除选中的 ${changed} 人,成绩与积分流水一并删除,不可恢复。` } // 本页只操作当前小项;重算按 (小项, 组别) 走,所以组别数就是这里的规模。 if (scopeCount > 0) { details.push(`会重算 1 个小项(当前项目)、${scopeCount} 个组别的名次与积分。`) } if (action === 'remove' && changed > MAX_LISTED_PEOPLE) { message += `这会重算 1 个小项(当前项目)、${scopeCount} 个组别,别人的名次会变。` } return { action, ids, changed, skipped, scopeCount, message, details } } /** 批量操作成功后的 toast 副标题:逐条成败 + 删除规模 + 重算范围。 */ export function entryBatchOutcome(result: EntryBatchResult): string { const { summary } = result const parts: string[] = [] parts.push(`成功 ${summary.ok} 条${summary.skipped > 0 ? `(跳过 ${summary.skipped} 条)` : ''}`) if (summary.failed > 0) parts.push(`失败 ${summary.failed} 条`) if (summary.removedEntries > 0) parts.push(`删除报名 ${summary.removedEntries} 条`) if (summary.removedResults > 0) parts.push(`删除成绩 ${summary.removedResults} 条`) if (summary.removedLedger > 0) parts.push(`删除流水 ${summary.removedLedger} 条`) if (summary.recomputedEvents.length > 0) parts.push(`重算 ${summary.recomputedEvents.length} 个小项`) return parts.join(';') } function readCsrfToken(): string | null { if (typeof document === 'undefined') return null const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie) return match?.[1] === undefined ? null : decodeURIComponent(match[1]) } function defaultRequestId(): string { const cryptoApi: Crypto | undefined = globalThis.crypto if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID() return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` } const KNOWN_CODES: readonly string[] = [ 'AUTH_REQUIRED', 'AUTH_FAILED', 'FORBIDDEN', 'VALIDATION_FAILED', 'CONFLICT', 'STATE_INVALID', 'RATE_LIMITED', 'INTERNAL', 'NETWORK', 'TIMEOUT', 'PARSE_FAILED', ] function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError { const safe: ApiErrorCode = typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL' return new ApiError(safe, message, status, detail) } /** * 提交批量操作。写操作不降级到示例数据:示例数据模式下的「成功」会让操作员 * 以为已经退赛 / 删干净了,页面必须先看 data.isMock 再决定是否调用这里。 */ export async function submitEntryBatch(payload: EntryBatchPayload): Promise { // 一次调用只生成一个幂等键:header 供服务端 cached 层去重,body.requestId 是同一把钥匙。 // 两处各生成一个就永远命中不了缓存,超时重试「移出报名」会真的把同一批人删两遍。 const requestId = payload.requestId ?? defaultRequestId() const headers: Record = { Accept: 'application/json', 'Content-Type': 'application/json; charset=utf-8', 'X-Request-Id': requestId, } const token = readCsrfToken() if (token !== null && token !== '') headers['X-CSRF-Token'] = token let response: Response try { response = await fetch(ENTRY_BATCH_PATH, { method: 'POST', headers, credentials: 'include', body: JSON.stringify({ ...payload, requestId }), }) } catch (caught) { throw apiError('NETWORK', '无法连接服务端,批量操作没有生效', 0, caught) } let body: unknown try { body = await response.json() } catch (caught) { throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught) } if (!isRecord(body) || typeof body['ok'] !== 'boolean') { throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body) } if (body['ok'] !== true) { const error = isRecord(body['error']) ? body['error'] : {} const message = typeof error['message'] === 'string' ? error['message'] : `批量操作失败(HTTP ${response.status})` throw apiError(error['code'], message, response.status, error['detail']) } if (!response.ok) { throw apiError('INTERNAL', `批量操作失败(HTTP ${response.status})`, response.status, body) } return body['data'] as EntryBatchResult }