import { firstNumberOrNull, isRawBody, isRecord, readString } from './internal' import { normalizeRulePack } from './rules' import { readTeamRanksView, readTeamRanksViews } from './teamRanks' import { parseYaml } from './yaml' import type { AdjustmentInput, ApiErrorCode, Athlete, AthleteInput, AthleteStanding, AuditLog, AuditLogQuery, BackupRestoreInput, BackupRestoreResult, CurrentUser, DisplayCommand, EntryHeatPayload, EntryHeatResult, EntryImportResult, EntryWithResult, Event, EventCategory, EventCategoryInput, EventInput, EventSettings, HealthInfo, ImportConflictDecision, LiveEvents, NetworkAddress, NetworkAddressScope, NetworkInfo, NetworkSkippedInterface, Page, PageQuery, PointAdjustment, ProjectActivateResult, ProjectInfo, PublishPayload, RecordBreak, RecordBreakInput, RecordBreakState, RecordEntry, RecordEntryInput, ReopenResultPayload, Result, ResultQuery, RulePack, RuleSwitches, SaveDraftPayload, ScoreChannel, ScoreChannelInput, TeamRanksInput, TeamRanksView, ScoringProfile, ScoringProfileInput, Sport, SportInput, StandingRow, Team, TeamBreakdown, TeamInput, VoidResultPayload, } from './types' export type QueryValue = string | number | boolean | null | undefined export type QueryParams = Record export interface ApiClientOptions { /** 基础路径,默认 `/api`;Electron 宿主或反向代理下可传绝对地址。 */ baseUrl?: string /** 会话是 HttpOnly Cookie,默认 `include` 才能带上。 */ credentials?: RequestCredentials /** CSRF Token 来源;默认读非 HttpOnly 的 csrf_token Cookie(登录时下发)。 */ csrfToken?: () => string | null /** 单次请求超时;现场局域网也可能出现半开连接,必须能主动放弃。 */ timeoutMs?: number /** 幂等键生成器;重试写操作时复用同一个 requestId,服务端据此返回首次结果。 */ requestId?: () => string onUnauthorized?: (error: ApiError) => void fetchImpl?: typeof fetch } interface RequestOptions { method?: HttpMethod /** 传接口对象即可;接口没有索引签名,所以这里用 object 接收后运行时收窄。 */ query?: object body?: unknown signal?: AbortSignal /** * 单次请求的超时覆盖值(毫秒);不传用客户端默认的 `ApiClientOptions.timeoutMs`。 * * 停机类动作(用备份恢复整库)在服务端要拍安全副本、换文件、校验、再写审计, * 回执是**全部做完之后**才发出的:默认 15s 会在库已经换掉之后把请求判成超时, * 于是客户端以为「没执行」而重试。这类接口必须单独放大超时。 */ timeoutMs?: number } type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' /** 统一响应约定见 05-接口与实时协议 第 6 节。 */ export class ApiError extends Error { readonly code: ApiErrorCode readonly status: number readonly detail: unknown constructor(code: ApiErrorCode, message: string, status: number, detail?: unknown) { super(message) this.name = 'ApiError' this.code = code this.status = status this.detail = detail } } const API_ERROR_CODES: readonly string[] = [ 'AUTH_REQUIRED', 'AUTH_FAILED', 'FORBIDDEN', 'VALIDATION_FAILED', 'CONFLICT', 'STATE_INVALID', 'RATE_LIMITED', 'INTERNAL', 'NETWORK', 'TIMEOUT', 'PARSE_FAILED', 'SAMPLE_DATA_BLOCKED', ] function isApiErrorCode(value: unknown): value is ApiErrorCode { return typeof value === 'string' && API_ERROR_CODES.includes(value) } /** 组别 / 通道随小项整体保存,避免只写了一半的配置被当成有效配置。 */ export interface CrudApi { list(): Promise get(id: number): Promise create(input: Input): Promise // 服务端 PUT 是整行覆盖(repo.go 的 UPDATE 写全部列),局部更新会把未传字段清空, // 所以这里要求完整 input,不能声明成 Partial。 update(id: number, input: Input): Promise remove(id: number): Promise } export interface AuthApi { login(username: string, password: string): Promise changePassword(currentPassword: string, newPassword: string): Promise<{ relogin: boolean }> logout(): Promise me(): Promise } export interface SettingsApi { get(): Promise update(patch: Partial): Promise } /** * 成绩录入接口的完整读数(`GET /api/events/{id}/entries`)。 * * 旧服务端只回成绩行数组;设计 17 §七 之后同一响应会随行带上该小项的团体手工名次, * 录入页一次请求就能把「成绩行 + 团体名次」都拿到,少发一轮。 */ export interface EntryBoard { entries: EntryWithResult[] /** 随行下发的团体名次视图;只有存在团体手工通道的组别才非空,拿不到时为空数组。 */ teamRanks: TeamRanksView[] } export interface EventsApi extends CrudApi { entries(eventId: number): Promise /** 与 entries 同一次请求,但把随行下发的 teamRanks 一并交给调用方。 */ entryBoard(eventId: number): Promise /** * 读某个组别的团体手工名次(`GET /api/events/{id}/categories/{cid}/team-ranks`)。 * * 组别下没有团体手工通道时服务端回一组空数组,不是 404——录入页据此隐藏区块, * 不需要先猜有没有这个接口。 */ teamRanks(eventId: number, categoryId: number): Promise /** * 写某个组别的团体手工名次(`PUT` 同一路径),整组替换。 * * `ranks[].rank: 0` 表示清空该单位;服务端在同一事务里重算该组名次与积分。 * 返回保存后的视图(口径与 GET 一致),调用方直接拿来回填。 */ saveTeamRanks(eventId: number, categoryId: number, input: TeamRanksInput): Promise categories(eventId: number): Promise saveCategories(eventId: number, categories: EventCategoryInput[]): Promise /** * 就地更新单个组别(`PUT /api/events/{id}/categories/{categoryId}`)。 * * 与 saveCategories 的整表替换不同,服务端只发一条 UPDATE:组别 id 不变, * 挂在它下面的报名、成绩、积分流水与名次全部原地保留。有报名数据的小项靠它 * 改名 / 改年龄 / 改性别 / 改权重;增删组别仍只能走 saveCategories(有报名数据时会被拒)。 * * 注意:权重是算分输入——`multiplier` 真的变了时服务端会在同一事务里重算该组已发布成绩的 * 积分与名次(别人的分值/榜单会跟着变);name / gender / ageMin / ageMax / sortOrder 不重算。 * 返回该小项最新的组别列表。 */ updateCategory(eventId: number, categoryId: number, category: EventCategoryInput): Promise channels(eventId: number): Promise saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise } /** * 彻底删除的规模统计(服务端 `athletePurge`),同时进审计日志。 * * 名次与积分会随成绩消失而变化,所以 `recomputedEvents` 是给操作员看的重点: * 删掉一个人之后,哪些小项的榜单被重算了。 */ export interface AthletePurgeResult { deletedEntries: number deletedResults: number deletedLedger: number deletedBreaks: number /** 因成绩删除而重算了积分与名次的小项 ID。 */ recomputedEvents: number[] } export interface AthletesApi extends CrudApi { /** * 彻底删除(`DELETE /api/athletes/{id}?purge=1`)。 * * 与 `remove` 的软删除(停用,`active = 0`,数据都在)不同,这是物理删除: * 服务端在同一事务里删掉该人员的人员行、报名、成绩与相关积分流水, * 并按新排序重算受影响小项的积分与名次(别人的名次会变),随后推送大屏刷新。 * 人员已被别人删掉时回 404。 */ purge(id: number): Promise /** * 批量停用 / 批量彻底删除(`POST /api/athletes/batch`)。 * * 逐个调 DELETE 是「一次操作一条请求」,现场清理几十个人时会直接撞上写限流; * 收成一次请求既少一次配额,响应里还逐条回报成败(有人已被别人删掉、有人 id 不存在)。 * dryRun = true 时只回报「会删掉什么、重算哪些小项」,不改库,供危险确认框先展示。 */ batch(action: AthleteBatchAction, ids: number[], dryRun?: boolean): Promise } /** 批量动作的语义:与单条停用 / 单条彻底删除完全一致。 */ export type AthleteBatchAction = 'disable' | 'purge' /** 批量动作里逐条的结果:个别失败不能让整批失败,也不能静默跳过。 */ export interface AthleteBatchItem { id: number ok: boolean /** 失败原因的错误码(目前只有 NOT_FOUND:该 id 在库里不存在)。 */ code?: string message?: string } export interface AthleteBatchResult { action: AthleteBatchAction /** true 表示这是预览(dryRun),服务端一行都没动。 */ dryRun?: boolean /** 去重后的 id 数。 */ requested: number succeeded: number failed: number items: AthleteBatchItem[] /** 只有 action = 'purge' 时出现:合计删除规模与受影响小项。 */ purge?: AthletePurgeResult } export interface TeamsApi extends CrudApi { /** * 单位列表。与 CrudApi.list 的差别只有 includeDeleted 一个开关。 * * `includeDeleted: true` 时连「彻底删除」(对界面不可见)的单位一并返回,供管理端 * 把它们翻出来恢复;服务端只放 admin 用这个开关,其余角色 403。 */ list(query?: TeamsListQuery): Promise /** * 彻底删除(`DELETE /api/teams/{id}?purge=1`)。 * * 注意口径与 AthletesApi.purge 不同:这里是「假删除」——只让该单位从默认列表里消失, * 报名、成绩、积分流水与人工调整一行未动,积分榜不变,也不会有任何重算。 * 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。 */ purge(id: number): Promise /** * 恢复被彻底删除的单位(`POST /api/teams/{id}/restore`)。 * * 只是把「不可见」的标记撤掉:报名、成绩与积分一行没动,所以不会重算、也不会推榜单。 * 幂等——单位本来就没被隐藏时照样 200,`restored` 为 false;单位不存在时 404。 */ restore(id: number): Promise } /** 单位列表的查询开关。 */ export interface TeamsListQuery { /** true 时连「彻底删除」的单位一并返回(仅 admin)。 */ includeDeleted?: boolean } /** 恢复被彻底删除单位的结果;restored 为 false 表示它本来就没被隐藏(幂等命中)。 */ export interface TeamRestoreResult { team: Team restored: boolean } export interface EntriesApi { /** 报名表按文件导入,后端解析 xlsx。 */ importFile(file: File): Promise /** 用原报名表 + 冲突决策重跑导入,服务端按决策保留一侧人员字段。 */ resolveImport(file: File, decisions: ImportConflictDecision[]): Promise /** * 批量设置 / 清除批次(heat)。 * * 幂等;要求 entryIds 非空、都属于同一个小项,heat 为 1-999 的整数或 null(清除)。 * 只改录入分组,不影响任何成绩与积分。 */ setHeat(payload: EntryHeatPayload): Promise /** * 「按小项 × 组别分工作表」的报名模板(xlsx),服务端按当前赛事的基础数据生成。 * * language 只决定表头语言(zh 用中文列名、en 用英文规范名),与单表 CSV 模板的两个按钮同一套口径。 * 服务端还会拒绝生成:名字里带 @ 的小项 / 组别、或者还没有任何「小项 + 组别」组合时, * 回的是一份 {ok:false} 的说明,调用方按普通 ApiError 展示即可。 */ downloadTemplate(language: 'zh' | 'en'): Promise } /** POST /api/uploads 的响应:path 是服务端存储后的路径,url 与其相同,可直接放进 。 */ export interface UploadResult { path: string url: string } export interface UploadsApi { /** 仅图片(png / jpeg / webp / gif),单文件上限由服务端限制。 */ upload(file: File): Promise } export interface ResultsApi { list(query: ResultQuery): Promise> saveDraft(payload: SaveDraftPayload): Promise publish(payload: PublishPayload): Promise unpublish(payload: PublishPayload): Promise void(id: number, payload: VoidResultPayload): Promise /** * 把一条已作废的成绩退回草稿(`POST /api/results/{id}/reopen`)。 * * 与 void 成对:作废只把 `status` 改成 withdrawn 并撤掉这一条的积分, * 想把这一条改回来只能先 reopen 退回草稿,再用常规的存草稿 / 发布流程重新发。 * 服务端在同一事务里重算推榜,返回更新后的成绩。 */ reopen(id: number, payload: ReopenResultPayload): Promise } export interface StandingsApi { teams(): Promise athletes(): Promise breakdown(teamId: number): Promise } export interface AdjustmentsApi { list(query?: PageQuery & { teamId?: number }): Promise> create(input: AdjustmentInput): Promise void(id: number, reason: string): Promise } export interface RecordsApi { list(query?: { eventId?: number }): Promise createBreak(input: RecordBreakInput): Promise approveBreak(id: number): Promise /** * 破纪录登记列表(`GET /api/record-breaks?state=...`,recorder 及以上可读)。 * * 不传 state 时由服务端决定(当前返回全部);按 id 降序。 * 「已批准的破纪录」卡片要 state=approved,仪表盘的「待确认」计数要 state=pending。 */ listBreaks(state?: RecordBreakState): Promise /** * 驳回 / 作废一条登记(`POST /api/record-breaks/{id}/reject`,原因必填)。 * * 原本是 approved 的登记被驳回时,服务端会撤掉那笔加分并把该级别纪录恢复成上一条。 */ rejectBreak(id: number, reason: string): Promise /** 登记一条历史纪录(`POST /api/records`);服务端固定把 source 写成 history。 */ createRecord(input: RecordEntryInput): Promise /** * 删除一条纪录(`DELETE /api/records/{id}`)。 * * 删掉的若是当前纪录(isCurrent),服务端会把同级别的上一条恢复成当前。 */ removeRecord(id: number): Promise } /** * 在线大屏实例(`GET /api/display/instances`)。 * * 管理端的「在线大屏」列表与「本机显示器当前在哪一页」都靠它; * `remoteAddr` 是局域网浏览器唯一的身份线索,`screen` 是本机显示器与实例的对应关系。 */ export interface DisplayInstance { /** Console 大屏窗口带来的 id,或服务端生成的 display-xxxx。 */ id: string online: boolean /** 当前所在页;没上报过时服务端因 omitempty 根本不带这个字段,这里归一成空串。 */ page: string /** 最后心跳(ISO 时间串)。 */ lastHeartbeat: string connectedAt: string /** 来源 IP:端口;同样可能缺字段,归一成空串。 */ remoteAddr: string ackVersion: number /** 版本落后到阈值以上:大屏可能在卡 / 网络差。 */ slow: boolean /** 屏号;局域网浏览器直接开 /display 时是空串。 */ screen: string } export interface DisplayApi { command(command: DisplayCommand): Promise /** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */ instances(): Promise /** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */ live(): Promise } export interface ExportApi { /** 把服务端返回的 Blob 存成文件(积分榜 / 结果导出的唯一入口)。 */ save(blob: Blob, filename: string): void } export interface AuditApi { list(query?: AuditLogQuery): Promise> } export interface RulesApi { /** GET /api/rules/export,返回当前规则包。 */ get(): Promise /** GET /api/rules/switches,recorder 可读的布尔开关(不含规则内容)。 */ switches(): Promise } export interface SystemApi { /** * 用一份备份恢复当前库(`POST /api/backup/restore`,仅 admin)。 * * 这是停机动作:服务端会先留一份恢复前安全副本,再换掉库文件,然后优雅退出、 * 由 Console 拉起。调用方拿到 `restarting: true` 后必须轮询健康检查等服务端回来, * 不能像普通写操作那样立刻认为已经生效(与切换赛事同一套等待逻辑)。 */ restore(input: BackupRestoreInput): Promise /** * 本机局域网地址(`GET /api/system/network`,recorder 及以上可读)。 * * 交给大屏控制页拼「局域网大屏」地址用;访客请求会吃 403,调用方按权限不足处理。 */ network(): Promise } /** * 赛事项目切换。 * * 服务端一次只打开一个赛事项目(启动参数 -project → 记忆文件 → default), * 其余项目在数据目录里只是「躺着的数据」:导入赛事包并不会让它变成当前赛事,必须显式切换。 */ export interface ProjectsApi { /** GET /api/projects:列出数据目录下的赛事项目及当前打开的那个。 */ list(): Promise /** POST /api/projects/activate:切换当前赛事;服务端会退出并由 Console 自动拉起。 */ activate(name: string): Promise } /** GET /api/system/about 的响应(recorder 及以上可读)。注入字段缺失或未注入时为空串。 */ export interface SystemAbout { internalSignature?: string buildTime?: string } interface Http { request(path: string, options?: RequestOptions): Promise requestVoid(path: string, options?: RequestOptions): Promise requestJson(path: string, options?: RequestOptions): Promise requestText(path: string, options?: RequestOptions): Promise requestBlob(path: string, options?: RequestOptions): Promise } function createCrudApi(http: Http, base: string): CrudApi { return { list: () => http.request(base), get: (id) => http.request(`${base}/${id}`), create: (input) => http.request(base, { method: 'POST', body: input }), update: (id, input) => http.request(`${base}/${id}`, { method: 'PUT', body: input }), remove: (id) => http.requestVoid(`${base}/${id}`, { method: 'DELETE' }), } } /** * 读删除统计。 * * 统计缺失时按 0 / 空数组处理:删除在服务端已经提交、不可回滚,这时提示里冒出 undefined * 或直接报「删除失败」都是在骗操作员。老服务端不认 ?purge=1、data 为 null 时也会走到这里。 */ function readPurgeResult(value: unknown): AthletePurgeResult { const source = isRecord(value) ? value : {} const count = (key: string): number => { const raw = source[key] return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0 } const events = source['recomputedEvents'] return { deletedEntries: count('deletedEntries'), deletedResults: count('deletedResults'), deletedLedger: count('deletedLedger'), deletedBreaks: count('deletedBreaks'), recomputedEvents: Array.isArray(events) ? events.filter((item): item is number => typeof item === 'number') : [], } } /** * 读批量动作的响应。 * * 逐条结果缺失时按「这一条失败」处理而不是成功:批量删除不可回滚,把没看到的 id 当成功 * 比多说一句「未能确认」危险得多。requested / succeeded / failed 也按 items 重算, * 老服务端字段缺失时不至于报出「0 人成功」这种自相矛盾的结论。 */ function readBatchResult(action: AthleteBatchAction, value: unknown): AthleteBatchResult { const source = isRecord(value) ? value : {} const items: AthleteBatchItem[] = Array.isArray(source['items']) ? source['items'].filter(isRecord).map((raw) => ({ id: typeof raw['id'] === 'number' ? raw['id'] : 0, ok: raw['ok'] === true, code: typeof raw['code'] === 'string' ? raw['code'] : undefined, message: typeof raw['message'] === 'string' ? raw['message'] : undefined, })) : [] const purge = source['purge'] return { action, dryRun: source['dryRun'] === true, requested: items.length, succeeded: items.filter((item) => item.ok).length, failed: items.filter((item) => !item.ok).length, items, purge: isRecord(purge) ? readPurgeResult(purge) : undefined, } } function createTeamsApi(http: Http): TeamsApi { return { ...createCrudApi(http, '/teams'), // 默认列表不带查询串(服务端对非 admin 的 includeDeleted 回 403,不能默认带上)。 list: (query) => http.request('/teams', { query: query?.includeDeleted === true ? { includeDeleted: 1 } : undefined, }), // 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;和运动员不同,这里是空响应。 purge: (id) => http.requestVoid(`/teams/${id}`, { method: 'DELETE', query: { purge: 1 } }), // 恢复是写操作:服务端按 X-Request-Id 幂等回放,重复点也只会生效一次。 restore: (id) => http.request(`/teams/${id}/restore`, { method: 'POST' }), } } function createAthletesApi(http: Http): AthletesApi { return { ...createCrudApi(http, '/athletes'), // 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。 purge: async (id) => readPurgeResult(await http.request(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })), batch: async (action, ids, dryRun = false) => // dryRun 缺省不传:老服务端不认这个字段时仍按真删处理,不能被预览悄悄降级。 readBatchResult( action, await http.request('/athletes/batch', { method: 'POST', body: dryRun ? { action, ids, dryRun: true } : { action, ids }, }), ), } } /** 团体手工名次的路径:GET 读、PUT 写同一个地址。 */ function teamRanksPath(eventId: number, categoryId: number): string { return `/events/${eventId}/categories/${categoryId}/team-ranks` } /** * 成绩录入接口的原始响应:裸数组(旧服务端)或 `{entries, teamRanks}`(设计 17 之后)。 * * 两种形状都能读;都不像时抛 PARSE_FAILED 而不是给空成绩单—— * 把「解析不出来」显示成「这个组没有报名项」,现场会以为报名数据丢了。 */ function readEntryBoard(value: unknown): EntryBoard { if (Array.isArray(value)) { return { entries: value as EntryWithResult[], teamRanks: [] } } if (isRecord(value) && Array.isArray(value.entries)) { return { entries: value.entries as EntryWithResult[], teamRanks: readTeamRanksViews(value.teamRanks), } } throw new ApiError('PARSE_FAILED', '成绩录入接口响应格式不符', 0, value) } function createEventsApi(http: Http): EventsApi { const listBoard = async (eventId: number): Promise => readEntryBoard(await http.request(`/events/${eventId}/entries`)) return { ...createCrudApi(http, '/events'), entries: async (eventId) => (await listBoard(eventId)).entries, entryBoard: listBoard, teamRanks: async (eventId, categoryId) => readTeamRanksView(await http.request(teamRanksPath(eventId, categoryId))), saveTeamRanks: async (eventId, categoryId, input) => readTeamRanksView( await http.request(teamRanksPath(eventId, categoryId), { method: 'PUT', body: input }), ), categories: (eventId) => http.request(`/events/${eventId}/categories`), saveCategories: (eventId, categories) => http.request(`/events/${eventId}/categories`, { method: 'PUT', body: { categories }, }), updateCategory: (eventId, categoryId, category) => http.request(`/events/${eventId}/categories/${categoryId}`, { method: 'PUT', body: category, }), channels: (eventId) => http.request(`/events/${eventId}/channels`), saveChannels: (eventId, channels) => http.request(`/events/${eventId}/channels`, { method: 'PUT', body: { channels }, }), } } function createResultsApi(http: Http): ResultsApi { return { list: (query) => http.request>('/results', { query }), saveDraft: (payload) => http.request('/results/draft', { method: 'PUT', body: payload }), publish: (payload) => http.request('/results/publish', { method: 'POST', body: payload }), unpublish: (payload) => http.request('/results/unpublish', { method: 'POST', body: payload }), void: (id, payload) => http.request(`/results/${id}/void`, { method: 'POST', body: payload }), reopen: (id, payload) => http.request(`/results/${id}/reopen`, { method: 'POST', body: payload }), } } /** 导出只剩下「把 Blob 存成文件」这一件事:路径与文件名由各页面按服务端 Content-Disposition 拼。 */ function createExportApi(): ExportApi { return { save: (blob, filename) => { const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.rel = 'noopener' anchor.click() // 立即 revoke 会在部分浏览器里取消下载,等一帧再释放。 setTimeout(() => URL.revokeObjectURL(url), 0) }, } } function createRulesApi(http: Http): RulesApi { return { // 导出接口返回的是裸 YAML 文本(不是 {ok,data}),必须按文本取回再解析。 get: async () => { const text = await http.requestText('/rules/export') let parsed: unknown try { parsed = parseYaml(text) } catch (error) { throw new ApiError('PARSE_FAILED', '规则包 YAML 解析失败', 0, error) } const pack = normalizeRulePack(parsed) if (pack === null) { throw new ApiError('PARSE_FAILED', '规则包格式不符(需要 scoreboard-rules v1)', 0, parsed) } return pack }, switches: () => http.request('/rules/switches'), } } /** * 逐条归一化大屏实例。 * * 服务端 `page` / `remoteAddr` 带 omitempty,缺字段时这里补空串——否则界面会把 undefined * 直接渲染出来;id 都没有的行算脏数据,丢掉。 */ function readDisplayInstance(value: unknown): DisplayInstance | null { if (!isRecord(value)) return null const id = readString(value, 'id') if (id === null || id === '') return null const ackVersion = value['ackVersion'] return { id, online: value['online'] === true, page: readString(value, 'page') ?? '', lastHeartbeat: readString(value, 'lastHeartbeat') ?? '', connectedAt: readString(value, 'connectedAt') ?? '', remoteAddr: readString(value, 'remoteAddr') ?? '', ackVersion: typeof ackVersion === 'number' && Number.isFinite(ackVersion) ? ackVersion : 0, slow: value['slow'] === true, screen: readString(value, 'screen') ?? '', } } function readProjectInfo(value: unknown): ProjectInfo | null { if (!isRecord(value)) return null const name = readString(value, 'name') if (name === null) return null return { name, active: value.active === true, // 缺字段时按「有库」处理:真正缺库时激活接口会给出中文报错,不该在列表里被静默禁掉。 hasEvent: value.hasEvent !== false, sizeBytes: typeof value.sizeBytes === 'number' && Number.isFinite(value.sizeBytes) ? value.sizeBytes : 0, updatedAt: readString(value, 'updatedAt'), } } function readNetworkAddress(value: unknown): NetworkAddress | null { if (!isRecord(value)) return null const ip = readString(value, 'ip') // 没有 ip 的行拼不出地址,留着只会在卡片上多出一条空链接。 if (ip === null || ip === '') return null return { ip, interface: readString(value, 'interface') ?? '', family: readString(value, 'family') ?? 'ipv4', // 只认服务端契约里的 low / route 两个「需要额外提示」的值:老服务端没有这个字段 // (它的地址本来就走严格轮),值也不认识时按 high 处理——凭一个没见过的值在卡片上 // 多印一句提示,是凭空造出来的噪声。 confidence: value.confidence === 'low' || value.confidence === 'route' ? value.confidence : 'high', // 类别同样只认契约里的三个值;认不出(老服务端)时留 undefined,卡片就不加类别说明。 // 绝不按 IP 前缀在这里补一个:分类是服务端的知识,前端猜出来的值会和它打架。 scope: readScope(value.scope), } } function readScope(value: unknown): NetworkAddressScope | undefined { return value === 'private' || value === 'global' || value === 'link-local' ? value : undefined } /** 归一化 `skipped[]` 的一条:没有 code 的行解释不了任何事,直接丢掉。 */ function readNetworkSkipped(value: unknown): NetworkSkippedInterface | null { if (!isRecord(value)) return null const code = readString(value, 'code') if (code === null || code === '') return null const rawAddresses = Array.isArray(value['addresses']) ? value['addresses'] : [] return { interface: readString(value, 'interface') ?? '', code, addresses: rawAddresses.filter((item): item is string => typeof item === 'string'), } } /** * 归一化 `GET /api/system/network`。 * * 端口/路径是拼地址的原料,前端不替服务端猜:缺端口就留 0,由页面判断「读不到可用地址」 * 并给说明——编一个默认端口出来会得到一条看着对、实际打不开的链接。 * addresses 不是数组时按「没检测到地址」处理,交给页面显示空态。 * skipped 不是数组时归一化成空数组(等价于「没有可解释的网卡」):老服务端压根不返回这个 * 字段,卡片据此退回原来那句空态文案,行为与升级前一致。 */ function readNetworkInfo(value: unknown): NetworkInfo { const port = firstNumberOrNull(isRecord(value) ? value['port'] : null) const rawAddresses = isRecord(value) && Array.isArray(value['addresses']) ? value['addresses'] : [] const rawSkipped = isRecord(value) && Array.isArray(value['skipped']) ? value['skipped'] : [] return { port: port !== null && port > 0 ? Math.floor(port) : 0, displayPath: normalizePath(readString(value, 'displayPath'), '/display'), adminPath: normalizePath(readString(value, 'adminPath'), '/admin'), addresses: rawAddresses .map(readNetworkAddress) .filter((item): item is NetworkAddress => item !== null), skipped: rawSkipped .map(readNetworkSkipped) .filter((item): item is NetworkSkippedInterface => item !== null), } } /** 服务端没给路径时退回约定值;补前导斜杠,免得拼出 `http://ip:portdisplay`。 */ function normalizePath(value: string | null, fallback: string): string { const path = value === null || value.trim() === '' ? fallback : value.trim() return path.startsWith('/') ? path : `/${path}` } function createProjectsApi(http: Http): ProjectsApi { return { list: async () => { const body = await http.request('/projects') // 服务端回 { projects: [...] };裸数组也接受,省得为了外层字段名再改一次前端。 const raw = Array.isArray(body) ? body : isRecord(body) && Array.isArray(body.projects) ? body.projects : null if (raw === null) throw new ApiError('PARSE_FAILED', '赛事项目列表响应格式不符', 0, body) return raw.map(readProjectInfo).filter((row): row is ProjectInfo => row !== null) }, activate: async (name) => { const body = await http.request('/projects/activate', { method: 'POST', body: { name }, }) // 字段缺失时按「不需要重启」处理:宁可让页面直接刷新,也不要卡在一个不会来的重启上。 return { name: isRecord(body) ? (readString(body, 'name') ?? name) : name, restarting: isRecord(body) && body.restarting === true, } }, } } function readCsrfCookie(): string | null { if (typeof document === 'undefined') return null const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie) const raw = match?.[1] return raw === undefined ? null : decodeURIComponent(raw) } 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)}` } /** * 恢复整库的超时(毫秒)。 * * 服务端的恢复是「结束请求才写回执」:拍安全副本、换文件、复核、写审计全部做完才 writeJSON。 * 而备份体积随赛事数据增长(几百 MB 很常见),默认 15s 会把「已经在换库」的请求判成超时; * 60s 相对默认留了 4 倍余量,又远小于现场操作员的等待耐心。 */ export const RESTORE_TIMEOUT_MS = 60_000 export class ApiClient { readonly auth: AuthApi readonly settings: SettingsApi readonly teams: TeamsApi readonly sports: CrudApi readonly events: EventsApi readonly scoringProfiles: CrudApi readonly athletes: AthletesApi readonly entries: EntriesApi readonly results: ResultsApi readonly standings: StandingsApi readonly adjustments: AdjustmentsApi readonly records: RecordsApi readonly display: DisplayApi readonly exports: ExportApi readonly auditLogs: AuditApi readonly rules: RulesApi readonly system: SystemApi readonly projects: ProjectsApi readonly uploads: UploadsApi private readonly baseUrl: string private readonly credentials: RequestCredentials private readonly timeoutMs: number private readonly csrfToken: () => string | null private readonly newRequestId: () => string private readonly onUnauthorized: ((error: ApiError) => void) | undefined private readonly fetchImpl: typeof fetch constructor(options: ApiClientOptions = {}) { this.baseUrl = (options.baseUrl ?? '/api').replace(/\/+$/, '') this.credentials = options.credentials ?? 'include' this.timeoutMs = options.timeoutMs ?? 15_000 this.csrfToken = options.csrfToken ?? readCsrfCookie this.newRequestId = options.requestId ?? defaultRequestId this.onUnauthorized = options.onUnauthorized this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis) const http: Http = { request: (path, requestOptions) => this.request(path, requestOptions), requestVoid: (path, requestOptions) => this.requestVoid(path, requestOptions), requestJson: (path, requestOptions) => this.requestJson(path, requestOptions), requestText: (path, requestOptions) => this.requestText(path, requestOptions), requestBlob: (path, requestOptions) => this.requestBlob(path, requestOptions), } this.auth = { login: (username, password) => http.request('/auth/login', { method: 'POST', body: { username, password }, }), changePassword: (currentPassword, newPassword) => http.request<{ relogin: boolean }>('/auth/password', { method: 'POST', body: { currentPassword, newPassword }, }), logout: () => http.requestVoid('/auth/logout', { method: 'POST' }), // 未登录时服务端回 200 + guest DTO,调用方用 isGuestUser(user) 判定,不能只看 null。 me: () => http.request('/auth/me'), } this.settings = { get: () => http.request('/settings'), update: (patch) => http.request('/settings', { method: 'PUT', body: patch }), } this.teams = createTeamsApi(http) this.sports = createCrudApi(http, '/sports') this.events = createEventsApi(http) this.scoringProfiles = createCrudApi(http, '/scoring-profiles') this.athletes = createAthletesApi(http) this.entries = { importFile: (file) => { const form = new FormData() form.append('file', file) return http.request('/entries/import', { method: 'POST', body: form }) }, resolveImport: (file, decisions) => { const form = new FormData() form.append('file', file) form.append('decisions', JSON.stringify(decisions)) return http.request('/entries/import/resolve', { method: 'POST', body: form }) }, setHeat: (payload) => http.request('/entries/heat', { method: 'PUT', body: payload }), downloadTemplate: (language) => http.requestBlob('/entries/template.xlsx', { query: { lang: language }, timeoutMs: 60_000 }), } this.results = createResultsApi(http) this.standings = { teams: () => http.request('/standings'), athletes: () => http.request('/standings/athletes'), breakdown: (teamId) => http.request(`/standings/teams/${teamId}/breakdown`), } this.adjustments = { list: (query) => http.request>('/adjustments', { query }), create: (input) => http.request('/adjustments', { method: 'POST', body: input }), void: (id, reason) => http.request(`/adjustments/${id}/void`, { method: 'POST', body: { reason }, }), } this.records = { list: (query) => http.request('/records', { query }), createBreak: (input) => http.request('/record-breaks', { method: 'POST', body: input }), approveBreak: (id) => http.request(`/record-breaks/${id}/approve`, { method: 'POST' }), // state 缺省时不带查询串:让服务端用自己的默认口径,前端不替它决定「全部」怎么写。 listBreaks: (state) => http.request('/record-breaks', { query: state === undefined ? undefined : { state }, }), rejectBreak: (id, reason) => http.request(`/record-breaks/${id}/reject`, { method: 'POST', body: { reason } }), createRecord: (input) => http.request('/records', { method: 'POST', body: input }), removeRecord: (id) => http.requestVoid(`/records/${id}`, { method: 'DELETE' }), } this.display = { command: (command) => http.requestVoid('/display/command', { method: 'POST', body: command }), instances: async () => { const body = await http.request('/display/instances') if (!Array.isArray(body)) { throw new ApiError('PARSE_FAILED', '大屏实例列表响应格式不符', 0, body) } return body.map(readDisplayInstance).filter((item): item is DisplayInstance => item !== null) }, live: () => http.request('/display/live'), } this.exports = createExportApi() this.auditLogs = { list: (query) => http.request>('/audit-logs', { query }), } this.rules = createRulesApi(http) this.system = { // 恢复是停机动作:服务端先拍安全副本、换库文件、复核新库、写审计,**全部做完才发回执**, // 库越大这一串越慢。用默认 15s 会在库已经换掉之后把请求判成超时, // 操作员看到「未执行」而重试,等于把刚恢复好的库再覆盖一遍,所以单独放大超时。 // 幂等(X-Request-Id)照旧:现场断网重试时,重复请求不能把库换两遍。 restore: (input) => http.request('/backup/restore', { method: 'POST', body: input, timeoutMs: RESTORE_TIMEOUT_MS, }), // request() 会把 {ok,data} 拆开,这里拿到的是 data 本体,再按字段归一化。 network: async () => readNetworkInfo(await http.request('/system/network')), } this.projects = createProjectsApi(http) this.uploads = { upload: (file) => { const form = new FormData() form.append('file', file) return http.request('/uploads', { method: 'POST', body: form }) }, } } /** 健康检查不走 {ok,data} 包装,直接返回 `{ok,version}`(05 第 7 节)。 */ async health(): Promise { const body = await this.requestJson('/health') if (isRecord(body) && body.ok === false) throw this.toApiError(body, 0) const version = readString(body, 'version') if (version === null) { throw new ApiError('PARSE_FAILED', '健康检查响应缺少 version 字段', 0, body) } return { version } } private async request(path: string, options: RequestOptions = {}): Promise { const body = await this.requestJson(path, options) return this.unwrap(body) } private async requestVoid(path: string, options: RequestOptions = {}): Promise { const body = await this.requestJson(path, options) // 204 或空响应视为成功;有响应体时仍校验 {ok:true}。 if (body === null || body === undefined) return this.unwrap(body) } /** * 401 也用于「原密码不正确」等业务失败(服务端回 AUTH_FAILED)。 * 只有会话缺失 / 过期(AUTH_REQUIRED)才需要通知调用方清空登录态。 */ private notifyUnauthorized(error: ApiError): void { if (error.code === 'AUTH_REQUIRED') this.onUnauthorized?.(error) } private async requestJson(path: string, options: RequestOptions = {}): Promise { const response = await this.send(path, options) const body = await readBody(response) if (response.status === 401) { const error = this.toApiError(body, response.status) this.notifyUnauthorized(error) throw error } if (!response.ok) throw this.toApiError(body, response.status) return body } /** 规则包导出等接口返回裸文本(非 JSON),错误体仍按 {ok,error} 解析。 */ private async requestText(path: string, options: RequestOptions = {}): Promise { const response = await this.send(path, options) if (!response.ok) { const body = await readBody(response) if (response.status === 401) { const error = this.toApiError(body, response.status) this.notifyUnauthorized(error) throw error } throw this.toApiError(body, response.status) } return response.text() } /** * 二进制下载(xlsx / zip):成功回 Blob,失败仍按 {ok,error} 解析后抛 ApiError。 * * 不能直接把下载地址塞进 :服务端拒绝下载时回的是一份 JSON 错误体, * 浏览器会把它当文件存下来,现场看到的是一个「下载成功了的报错文件」。 */ private async requestBlob(path: string, options: RequestOptions = {}): Promise { const response = await this.send(path, options) if (!response.ok) { const body = await readBody(response) if (response.status === 401) { const error = this.toApiError(body, response.status) this.notifyUnauthorized(error) throw error } throw this.toApiError(body, response.status) } return response.blob() } private async send(path: string, options: RequestOptions): Promise { const method: HttpMethod = options.method ?? 'GET' // 一次调用只用**一个**幂等键,header 与 body 共用它。 // 服务端是两层缓存:外层 cached 认 X-Request-Id,内层 idempotent 认 body.requestId, // 两边各生成一个就永远拼不出同一个 key,幂等缓存从未命中——现场超时后重试 // 「发布 / 撤销发布 / 存草稿 / 移出报名」会真的重复执行一遍。 // 调用方已经在 body 里给了 key(例如重发同一次写操作)就沿用它,否则这里新建一个。 const given = isRecord(options.body) ? options.body['requestId'] : undefined const requestId = method === 'GET' ? null : (typeof given === 'string' && given !== '' ? given : this.newRequestId()) const payload = this.prepareBody(method, options.body, requestId) // 断言:二进制 / 表单载荷只能原样透传,一旦被归一化展开就会静默丢内容(报名表导入踩过)。 if (isRawBody(options.body) && payload !== options.body) { throw new ApiError('INTERNAL', '请求体归一化破坏了 FormData/Blob 载荷', 0, options.body) } const headers: Record = { Accept: 'application/json' } if (payload instanceof FormData) { // 交给浏览器带 multipart boundary,手动设置 Content-Type 会丢 boundary。 } else if (isRawBody(payload)) { // Blob / URLSearchParams 自带类型语义,Content-Type 交给 fetch 决定。 } else if (payload !== undefined) { headers['Content-Type'] = 'application/json; charset=utf-8' } if (method !== 'GET') { const token = this.csrfToken() if (token !== null && token !== '') headers['X-CSRF-Token'] = token // 与 body.requestId 是同一个值:服务端两层幂等缓存(cached / idempotent)才能命中。 if (requestId !== null) headers['X-Request-Id'] = requestId } const timeoutMs = options.timeoutMs ?? this.timeoutMs const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) const external = options.signal let abortedByCaller = false if (external !== undefined) { const abortFromCaller = (): void => { abortedByCaller = true controller.abort() } if (external.aborted) abortFromCaller() else external.addEventListener('abort', abortFromCaller, { once: true }) } try { return await this.fetchImpl(this.buildUrl(path, options.query), { method, headers, credentials: this.credentials, body: payload === undefined ? undefined : isRawBody(payload) ? payload : JSON.stringify(payload), signal: controller.signal, }) } catch (error) { // 外部取消与服务端超时共用同一个 signal,必须分开归类,调用方才能决定要不要提示。 if (abortedByCaller) throw new ApiError('ABORTED', '请求已取消', 0, error) if (controller.signal.aborted) { throw new ApiError('TIMEOUT', `请求超时(${timeoutMs}ms)`, 0, error) } throw new ApiError('NETWORK', '无法连接服务端', 0, error) } finally { clearTimeout(timer) } } /** * 写操作注入 requestId:网络抖动重发时服务端能返回首次结果而不是重复执行。 * `requestId` 由 `send()` 生成一次并同时用于 header,两者必须是同一个值(见 send 的注释)。 */ private prepareBody(method: HttpMethod, body: unknown, requestId: string | null): unknown { if (method === 'GET' || body === undefined) return undefined // FormData/Blob/URLSearchParams 不能展开,否则文件/二进制内容会被丢掉。 if (isRawBody(body)) return body if (requestId !== null && isRecord(body) && !('requestId' in body)) { return { ...body, requestId } } return body } private unwrap(body: unknown): T { if (!isRecord(body) || typeof body.ok !== 'boolean') { throw new ApiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', 0, body) } if (body.ok) return body.data as T throw this.toApiError(body, 0) } private toApiError(body: unknown, status: number): ApiError { if (isRecord(body) && isRecord(body.error)) { const code = body.error.code const message = readString(body.error, 'message') return new ApiError( isApiErrorCode(code) ? code : 'INTERNAL', message ?? `请求失败(HTTP ${status})`, status, body.error.detail, ) } return new ApiError('INTERNAL', `请求失败(HTTP ${status})`, status, body) } private buildUrl(path: string, query?: object): string { const base = path.startsWith('/') ? `${this.baseUrl}${path}` : `${this.baseUrl}/${path}` if (query === undefined || query === null || !isRecord(query)) return base const params = new URLSearchParams() for (const [key, value] of Object.entries(query)) { if (Array.isArray(value)) { for (const item of value) { if (isScalar(item)) params.append(key, String(item)) } continue } if (isScalar(value)) params.append(key, String(value)) } const search = params.toString() return search === '' ? base : `${base}?${search}` } } function isScalar(value: unknown): value is string | number | boolean { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' } async function readBody(response: Response): Promise { if (response.status === 204) return null const text = await response.text() if (text.trim() === '') return null try { return JSON.parse(text) } catch { // 非 JSON 也原样返回,报错里带上原文才定位得到问题。 return text } } export function createApiClient(options: ApiClientOptions = {}): ApiClient { return new ApiClient(options) }