Files
Scoreboard/web/src/shared/api.ts
T
AskaEth 56ed90ff8b fix(display,entry): 修大屏团体名次显示「-」与分项成绩第 3 栏缺第 1 名;发布改弹窗选口径;会话设备名与重命名;录分搜索框;版本 dev_0.5.15
本批七项(三处是现场报的 bug,四处是新功能):

① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」
   根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成
   「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底
   (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。
   改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。
   ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次,
   若通道名次优先,个人项目会在那种发布之后显示旧名次。
   ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。
   ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。

② 分项成绩页第 3 栏看不到第 1 名
   根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏,
   切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉
   (手录项目从第 1 名往下录 → 第 1 名建号最早)。
   改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行);
   组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。
   ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行);
   将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。

③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起
   多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度
   (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。
   取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。

④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一
   默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次;
   Alt+Enter 走同一个入口;取消 = 什么都不发生。
   审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。

⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目
   - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报,
     服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。
     ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。
   - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的
     display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签;
     显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示,
     不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。
   - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目
     (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。
     现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。

⑥ 局域网地址条目都能出二维码
   以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示,
   再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG
   (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。

⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲)
   复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、
   全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」;
   × / Esc / 清除三种清空方式;搜不到给明确空状态。
   ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、
      预览算分一律不受影响,界面上也写明了这一点。
   ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩
      静默写到看不见的人身上。

测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok,
EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状
「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium)
实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。

无数据库迁移;已有成绩、名次、积分与榜单不变。
文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法;
《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步;
devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。
2026-09-17 18:19:38 +08:00

1312 lines
54 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 { 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,
ClearResultsPayload,
ClearResultsResult,
CurrentUser,
DisplayCommand,
Entry,
EntryHeatPayload,
EntryHeatResult,
EntryImportResult,
EntryTeamShare,
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<string, QueryValue | QueryValue[]>
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<T, Input> {
list(): Promise<T[]>
get(id: number): Promise<T>
create(input: Input): Promise<T>
// 服务端 PUT 是整行覆盖(repo.go 的 UPDATE 写全部列),局部更新会把未传字段清空,
// 所以这里要求完整 input,不能声明成 Partial。
update(id: number, input: Input): Promise<T>
remove(id: number): Promise<void>
}
export interface AuthApi {
login(username: string, password: string): Promise<CurrentUser>
changePassword(currentPassword: string, newPassword: string): Promise<{ relogin: boolean }>
logout(): Promise<void>
me(): Promise<CurrentUser>
}
export interface SettingsApi {
get(): Promise<EventSettings>
update(patch: Partial<EventSettings>): Promise<EventSettings>
}
/**
* 成绩录入接口的完整读数(`GET /api/events/{id}/entries`)。
*
* 旧服务端只回成绩行数组;设计 17 §七 之后同一响应会随行带上该小项的团体手工名次,
* 录入页一次请求就能把「成绩行 + 团体名次」都拿到,少发一轮。
*/
export interface EntryBoard {
entries: EntryWithResult[]
/** 随行下发的团体名次视图;只有存在团体手工通道的组别才非空,拿不到时为空数组。 */
teamRanks: TeamRanksView[]
}
export interface EventsApi extends CrudApi<Event, EventInput> {
entries(eventId: number): Promise<EntryWithResult[]>
/** 与 entries 同一次请求,但把随行下发的 teamRanks 一并交给调用方。 */
entryBoard(eventId: number): Promise<EntryBoard>
/**
* 读某个组别的团体手工名次(`GET /api/events/{id}/categories/{cid}/team-ranks`)。
*
* 组别下没有团体手工通道时服务端回一组空数组,不是 404——录入页据此隐藏区块,
* 不需要先猜有没有这个接口。
*/
teamRanks(eventId: number, categoryId: number): Promise<TeamRanksView>
/**
* 写某个组别的团体手工名次(`PUT` 同一路径),整组替换。
*
* `ranks[].rank: 0` 表示清空该单位;服务端在同一事务里重算该组名次与积分。
* 返回保存后的视图(口径与 GET 一致),调用方直接拿来回填。
*/
saveTeamRanks(eventId: number, categoryId: number, input: TeamRanksInput): Promise<TeamRanksView>
categories(eventId: number): Promise<EventCategory[]>
saveCategories(eventId: number, categories: EventCategoryInput[]): Promise<EventCategory[]>
/**
* 就地更新单个组别(`PUT /api/events/{id}/categories/{categoryId}`)。
*
* 与 saveCategories 的整表替换不同,服务端只发一条 UPDATE:组别 id 不变,
* 挂在它下面的报名、成绩、积分流水与名次全部原地保留。有报名数据的小项靠它
* 改名 / 改年龄 / 改性别 / 改权重;增删组别仍只能走 saveCategories(有报名数据时会被拒)。
*
* 注意:权重是算分输入——`multiplier` 真的变了时服务端会在同一事务里重算该组已发布成绩的
* 积分与名次(别人的分值/榜单会跟着变);name / gender / ageMin / ageMax / sortOrder 不重算。
* 返回该小项最新的组别列表。
*/
updateCategory(eventId: number, categoryId: number, category: EventCategoryInput): Promise<EventCategory[]>
channels(eventId: number): Promise<ScoreChannel[]>
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
}
/**
* 彻底删除的规模统计(服务端 `athletePurge`),同时进审计日志。
*
* 名次与积分会随成绩消失而变化,所以 `recomputedEvents` 是给操作员看的重点:
* 删掉一个人之后,哪些小项的榜单被重算了。
*/
export interface AthletePurgeResult {
deletedEntries: number
deletedResults: number
deletedLedger: number
deletedBreaks: number
/** 因成绩删除而重算了积分与名次的小项 ID。 */
recomputedEvents: number[]
}
export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
/**
* 彻底删除(`DELETE /api/athletes/{id}?purge=1`)。
*
* 与 `remove` 的软删除(停用,`active = 0`,数据都在)不同,这是物理删除:
* 服务端在同一事务里删掉该人员的人员行、报名、成绩与相关积分流水,
* 并按新排序重算受影响小项的积分与名次(别人的名次会变),随后推送大屏刷新。
* 人员已被别人删掉时回 404。
*/
purge(id: number): Promise<AthletePurgeResult>
/**
* 批量停用 / 批量彻底删除(`POST /api/athletes/batch`)。
*
* 逐个调 DELETE 是「一次操作一条请求」,现场清理几十个人时会直接撞上写限流;
* 收成一次请求既少一次配额,响应里还逐条回报成败(有人已被别人删掉、有人 id 不存在)。
* dryRun = true 时只回报「会删掉什么、重算哪些小项」,不改库,供危险确认框先展示。
*/
batch(action: AthleteBatchAction, ids: number[], dryRun?: boolean): Promise<AthleteBatchResult>
}
/** 批量动作的语义:与单条停用 / 单条彻底删除完全一致。 */
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<Team, TeamInput> {
/**
* 单位列表。与 CrudApi.list 的差别只有 includeDeleted 一个开关。
*
* `includeDeleted: true` 时连「彻底删除」(对界面不可见)的单位一并返回,供管理端
* 把它们翻出来恢复;服务端只放 admin 用这个开关,其余角色 403。
*/
list(query?: TeamsListQuery): Promise<Team[]>
/**
* 彻底删除(`DELETE /api/teams/{id}?purge=1`)。
*
* 注意口径与 AthletesApi.purge 不同:这里是「假删除」——只让该单位从默认列表里消失,
* 报名、成绩、积分流水与人工调整一行未动,积分榜不变,也不会有任何重算。
* 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。
*/
purge(id: number): Promise<void>
/**
* 恢复被彻底删除的单位(`POST /api/teams/{id}/restore`)。
*
* 只是把「不可见」的标记撤掉:报名、成绩与积分一行没动,所以不会重算、也不会推榜单。
* 幂等——单位本来就没被隐藏时照样 200,`restored` 为 false;单位不存在时 404。
*/
restore(id: number): Promise<TeamRestoreResult>
}
/** 单位列表的查询开关。 */
export interface TeamsListQuery {
/** true 时连「彻底删除」的单位一并返回(仅 admin)。 */
includeDeleted?: boolean
}
/** 恢复被彻底删除单位的结果;restored 为 false 表示它本来就没被隐藏(幂等命中)。 */
export interface TeamRestoreResult {
team: Team
restored: boolean
}
export interface EntriesApi {
/** 报名表按文件导入,后端解析 xlsx。 */
importFile(file: File): Promise<EntryImportResult>
/** 用原报名表 + 冲突决策重跑导入,服务端按决策保留一侧人员字段。 */
resolveImport(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult>
/**
* 批量设置 / 清除批次(heat)。
*
* 幂等;要求 entryIds 非空、都属于同一个小项,heat 为 1-999 的整数或 null(清除)。
* 只改录入分组,不影响任何成绩与积分。
*/
setHeat(payload: EntryHeatPayload): Promise<EntryHeatResult>
/**
* 改写一条报名的成员单位与份额(`PUT /api/entries/{id}/team-shares`,见 devdocs/19)。
*
* 整组替换、幂等(body.requestId 与 X-Request-Id 是同一个键):改完服务端在同一事务里
* 重算该 (小项, 组别) 的流水与名次并推榜,所以成功后的榜单已经是新口径。
* 校验失败回 400 + 人话 message(不能为空 / 必须含主单位 / 单位重复 / 单位不存在 / 权重为负 / 成员超限),
* 调用方原样展示那句话即可——前端不自己改写服务端的措辞。
*/
saveTeamShares(
entryId: number,
teamShares: EntryTeamShare[],
requestId?: string,
): Promise<Entry>
/**
* 「按小项 × 组别分工作表」的报名模板(xlsx),服务端按当前赛事的基础数据生成。
*
* language 只决定表头语言(zh 用中文列名、en 用英文规范名),与单表 CSV 模板的两个按钮同一套口径。
* 服务端还会拒绝生成:名字里带 @ 的小项 / 组别、或者还没有任何「小项 + 组别」组合时,
* 回的是一份 {ok:false} 的说明,调用方按普通 ApiError 展示即可。
*/
downloadTemplate(language: 'zh' | 'en'): Promise<Blob>
}
/** POST /api/uploads 的响应:path 是服务端存储后的路径,url 与其相同,可直接放进 <img src>。 */
export interface UploadResult {
path: string
url: string
}
export interface UploadsApi {
/** 仅图片(png / jpeg / webp / gif),单文件上限由服务端限制。 */
upload(file: File): Promise<UploadResult>
}
export interface ResultsApi {
list(query: ResultQuery): Promise<Page<Result>>
saveDraft(payload: SaveDraftPayload): Promise<Result[]>
publish(payload: PublishPayload): Promise<Result[]>
unpublish(payload: PublishPayload): Promise<Result[]>
/**
* 清空「当前组 / 批次」的成绩(`POST /api/results/clear`,危险操作)。
*
* = 撤销发布(收回分数、作废该范围的积分流水)+ 清空成绩内容(名次 / 成绩值 / 备注)。
* 请求体必须带 `confirm: true`(对应界面上那个必须勾选的框),服务端也据此兜底。
*/
clearResults(payload: ClearResultsPayload): Promise<ClearResultsResult>
void(id: number, payload: VoidResultPayload): Promise<Result>
/**
* 把一条已作废的成绩退回草稿(`POST /api/results/{id}/reopen`)。
*
* 与 void 成对:作废只把 `status` 改成 withdrawn 并撤掉这一条的积分,
* 想把这一条改回来只能先 reopen 退回草稿,再用常规的存草稿 / 发布流程重新发。
* 服务端在同一事务里重算推榜,返回更新后的成绩。
*/
reopen(id: number, payload: ReopenResultPayload): Promise<Result>
}
export interface StandingsApi {
teams(): Promise<StandingRow[]>
athletes(): Promise<AthleteStanding[]>
breakdown(teamId: number): Promise<TeamBreakdown>
}
export interface AdjustmentsApi {
list(query?: PageQuery & { teamId?: number }): Promise<Page<PointAdjustment>>
create(input: AdjustmentInput): Promise<PointAdjustment>
void(id: number, reason: string): Promise<PointAdjustment>
}
export interface RecordsApi {
list(query?: { eventId?: number }): Promise<RecordEntry[]>
createBreak(input: RecordBreakInput): Promise<RecordBreak>
approveBreak(id: number): Promise<RecordBreak>
/**
* 破纪录登记列表(`GET /api/record-breaks?state=...`,recorder 及以上可读)。
*
* 不传 state 时由服务端决定(当前返回全部);按 id 降序。
* 「已批准的破纪录」卡片要 state=approved,仪表盘的「待确认」计数要 state=pending。
*/
listBreaks(state?: RecordBreakState): Promise<RecordBreak[]>
/**
* 驳回 / 作废一条登记(`POST /api/record-breaks/{id}/reject`,原因必填)。
*
* 原本是 approved 的登记被驳回时,服务端会撤掉那笔加分并把该级别纪录恢复成上一条。
*/
rejectBreak(id: number, reason: string): Promise<RecordBreak>
/** 登记一条历史纪录(`POST /api/records`);服务端固定把 source 写成 history。 */
createRecord(input: RecordEntryInput): Promise<RecordEntry>
/**
* 删除一条纪录(`DELETE /api/records/{id}`)。
*
* 删掉的若是当前纪录(isCurrent),服务端会把同级别的上一条恢复成当前。
*/
removeRecord(id: number): Promise<void>
}
/**
* 在线大屏实例(`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
/**
* 设备标签(`安卓 13 · Chrome 120 · 1080×2400 · 手机`)。
*
* 老客户端 / 老服务端没有这个字段(omitempty),此时为 undefined——列表退回显示 id。
*/
device?: string
/** 原始 UA,悬浮提示排障用;同样可能缺字段。 */
deviceRaw?: string
/** 操作员手动设的名字(机器级,跟着实例 id 走);没设过时为 undefined。 */
alias?: string
}
export interface DisplayApi {
command(command: DisplayCommand): Promise<void>
/** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */
instances(): Promise<DisplayInstance[]>
/**
* 给某个实例设置 / 清除别名(`PUT /api/display/instances/{id}/alias`,录入员及以上)。
*
* 空串(或只空白)表示清除,实例回到自动标签;返回该实例的最新记录(形状与列表元素一致)。
*/
setAlias(id: string, alias: string): Promise<DisplayInstance>
/** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */
live(): Promise<LiveEvents>
}
export interface ExportApi {
/** 把服务端返回的 Blob 存成文件(积分榜 / 结果导出的唯一入口)。 */
save(blob: Blob, filename: string): void
}
export interface AuditApi {
list(query?: AuditLogQuery): Promise<Page<AuditLog>>
}
export interface RulesApi {
/** GET /api/rules/export,返回当前规则包。 */
get(): Promise<RulePack>
/** GET /api/rules/switches,recorder 可读的布尔开关(不含规则内容)。 */
switches(): Promise<RuleSwitches>
}
export interface SystemApi {
/**
* 用一份备份恢复当前库(`POST /api/backup/restore`,仅 admin)。
*
* 这是停机动作:服务端会先留一份恢复前安全副本,再换掉库文件,然后优雅退出、
* 由 Console 拉起。调用方拿到 `restarting: true` 后必须轮询健康检查等服务端回来,
* 不能像普通写操作那样立刻认为已经生效(与切换赛事同一套等待逻辑)。
*/
restore(input: BackupRestoreInput): Promise<BackupRestoreResult>
/**
* 本机局域网地址(`GET /api/system/network`,recorder 及以上可读)。
*
* 交给大屏控制页拼「局域网大屏」地址用;访客请求会吃 403,调用方按权限不足处理。
*/
network(): Promise<NetworkInfo>
}
/**
* 赛事项目切换。
*
* 服务端一次只打开一个赛事项目(启动参数 -project → 记忆文件 → default),
* 其余项目在数据目录里只是「躺着的数据」:导入赛事包并不会让它变成当前赛事,必须显式切换。
*/
export interface ProjectsApi {
/** GET /api/projects:列出数据目录下的赛事项目及当前打开的那个。 */
list(): Promise<ProjectInfo[]>
/** POST /api/projects/activate:切换当前赛事;服务端会退出并由 Console 自动拉起。 */
activate(name: string): Promise<ProjectActivateResult>
}
/** GET /api/system/about 的响应(recorder 及以上可读)。注入字段缺失或未注入时为空串。 */
export interface SystemAbout {
internalSignature?: string
buildTime?: string
}
interface Http {
request<T>(path: string, options?: RequestOptions): Promise<T>
requestVoid(path: string, options?: RequestOptions): Promise<void>
requestJson(path: string, options?: RequestOptions): Promise<unknown>
requestText(path: string, options?: RequestOptions): Promise<string>
requestBlob(path: string, options?: RequestOptions): Promise<Blob>
}
function createCrudApi<T, Input>(http: Http, base: string): CrudApi<T, Input> {
return {
list: () => http.request<T[]>(base),
get: (id) => http.request<T>(`${base}/${id}`),
create: (input) => http.request<T>(base, { method: 'POST', body: input }),
update: (id, input) => http.request<T>(`${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<Team, TeamInput>(http, '/teams'),
// 默认列表不带查询串(服务端对非 admin 的 includeDeleted 回 403,不能默认带上)。
list: (query) =>
http.request<Team[]>('/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<TeamRestoreResult>(`/teams/${id}/restore`, { method: 'POST' }),
}
}
function createAthletesApi(http: Http): AthletesApi {
return {
...createCrudApi<Athlete, AthleteInput>(http, '/athletes'),
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。
purge: async (id) =>
readPurgeResult(await http.request<unknown>(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })),
batch: async (action, ids, dryRun = false) =>
// dryRun 缺省不传:老服务端不认这个字段时仍按真删处理,不能被预览悄悄降级。
readBatchResult(
action,
await http.request<unknown>('/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<EntryBoard> =>
readEntryBoard(await http.request<unknown>(`/events/${eventId}/entries`))
return {
...createCrudApi<Event, EventInput>(http, '/events'),
entries: async (eventId) => (await listBoard(eventId)).entries,
entryBoard: listBoard,
teamRanks: async (eventId, categoryId) =>
readTeamRanksView(await http.request<unknown>(teamRanksPath(eventId, categoryId))),
saveTeamRanks: async (eventId, categoryId, input) =>
readTeamRanksView(
await http.request<unknown>(teamRanksPath(eventId, categoryId), { method: 'PUT', body: input }),
),
categories: (eventId) => http.request<EventCategory[]>(`/events/${eventId}/categories`),
saveCategories: (eventId, categories) =>
http.request<EventCategory[]>(`/events/${eventId}/categories`, {
method: 'PUT',
body: { categories },
}),
updateCategory: (eventId, categoryId, category) =>
http.request<EventCategory[]>(`/events/${eventId}/categories/${categoryId}`, {
method: 'PUT',
body: category,
}),
channels: (eventId) => http.request<ScoreChannel[]>(`/events/${eventId}/channels`),
saveChannels: (eventId, channels) =>
http.request<ScoreChannel[]>(`/events/${eventId}/channels`, {
method: 'PUT',
body: { channels },
}),
}
}
function createResultsApi(http: Http): ResultsApi {
return {
list: (query) => http.request<Page<Result>>('/results', { query }),
saveDraft: (payload) => http.request<Result[]>('/results/draft', { method: 'PUT', body: payload }),
publish: (payload) => http.request<Result[]>('/results/publish', { method: 'POST', body: payload }),
unpublish: (payload) =>
http.request<Result[]>('/results/unpublish', { method: 'POST', body: payload }),
clearResults: (payload) =>
http.request<ClearResultsResult>('/results/clear', { method: 'POST', body: payload }),
void: (id, payload) => http.request<Result>(`/results/${id}/void`, { method: 'POST', body: payload }),
reopen: (id, payload) =>
http.request<Result>(`/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<RuleSwitches>('/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']
const instance: DisplayInstance = {
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') ?? '',
}
// 这三个是后加的字段(老服务端不带):**缺字段时整个键不出现**,而不是补空串——
// 老服务端升级前那些用例是按「对象形状完全一致」钉住的,多一个空串键就会把它们判成回归。
// 只空白同理按「没有」处理:服务端把只空白当清除,界面这边也必须同口径。
const device = trimmed(readString(value, 'device'))
if (device !== null) instance.device = device
const deviceRaw = trimmed(readString(value, 'deviceRaw'))
if (deviceRaw !== null) instance.deviceRaw = deviceRaw
const alias = trimmed(readString(value, 'alias'))
if (alias !== null) instance.alias = alias
return instance
}
/** 空串 / 只空白都当成「没有这个值」;有内容时去掉首尾空白(粘贴来的名字几乎都带着)。 */
function trimmed(value: string | null): string | null {
if (value === null) return null
const text = value.trim()
return text === '' ? null : text
}
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<unknown>('/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<unknown>('/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<Sport, SportInput>
readonly events: EventsApi
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
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<CurrentUser>('/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<CurrentUser>('/auth/me'),
}
this.settings = {
get: () => http.request<EventSettings>('/settings'),
update: (patch) => http.request<EventSettings>('/settings', { method: 'PUT', body: patch }),
}
this.teams = createTeamsApi(http)
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
this.events = createEventsApi(http)
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
this.athletes = createAthletesApi(http)
this.entries = {
importFile: (file) => {
const form = new FormData()
form.append('file', file)
return http.request<EntryImportResult>('/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<EntryImportResult>('/entries/import/resolve', { method: 'POST', body: form })
},
setHeat: (payload) =>
http.request<EntryHeatResult>('/entries/heat', { method: 'PUT', body: payload }),
// 不在前端拼 requestId:send() 会生成一个并同时用于 header 与 body,
// 调用方只在重试同一次写操作时才显式传(与批量操作 / 存草稿同一口径)。
saveTeamShares: (entryId, teamShares, requestId) =>
http.request<Entry>(`/entries/${entryId}/team-shares`, {
method: 'PUT',
body: requestId === undefined ? { teamShares } : { teamShares, requestId },
}),
downloadTemplate: (language) =>
http.requestBlob('/entries/template.xlsx', { query: { lang: language }, timeoutMs: 60_000 }),
}
this.results = createResultsApi(http)
this.standings = {
teams: () => http.request<StandingRow[]>('/standings'),
athletes: () => http.request<AthleteStanding[]>('/standings/athletes'),
breakdown: (teamId) => http.request<TeamBreakdown>(`/standings/teams/${teamId}/breakdown`),
}
this.adjustments = {
list: (query) => http.request<Page<PointAdjustment>>('/adjustments', { query }),
create: (input) => http.request<PointAdjustment>('/adjustments', { method: 'POST', body: input }),
void: (id, reason) =>
http.request<PointAdjustment>(`/adjustments/${id}/void`, {
method: 'POST',
body: { reason },
}),
}
this.records = {
list: (query) => http.request<RecordEntry[]>('/records', { query }),
createBreak: (input) => http.request<RecordBreak>('/record-breaks', { method: 'POST', body: input }),
approveBreak: (id) =>
http.request<RecordBreak>(`/record-breaks/${id}/approve`, { method: 'POST' }),
// state 缺省时不带查询串:让服务端用自己的默认口径,前端不替它决定「全部」怎么写。
listBreaks: (state) =>
http.request<RecordBreak[]>('/record-breaks', {
query: state === undefined ? undefined : { state },
}),
rejectBreak: (id, reason) =>
http.request<RecordBreak>(`/record-breaks/${id}/reject`, { method: 'POST', body: { reason } }),
createRecord: (input) => http.request<RecordEntry>('/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<unknown>('/display/instances')
if (!Array.isArray(body)) {
throw new ApiError('PARSE_FAILED', '大屏实例列表响应格式不符', 0, body)
}
return body.map(readDisplayInstance).filter((item): item is DisplayInstance => item !== null)
},
setAlias: async (id, alias) => {
const body = await http.request<unknown>(
`/display/instances/${encodeURIComponent(id)}/alias`,
{ method: 'PUT', body: { alias } },
)
const instance = readDisplayInstance(body)
if (instance === null) {
throw new ApiError('PARSE_FAILED', '大屏实例别名响应格式不符', 0, body)
}
return instance
},
live: () => http.request<LiveEvents>('/display/live'),
}
this.exports = createExportApi()
this.auditLogs = {
list: (query) => http.request<Page<AuditLog>>('/audit-logs', { query }),
}
this.rules = createRulesApi(http)
this.system = {
// 恢复是停机动作:服务端先拍安全副本、换库文件、复核新库、写审计,**全部做完才发回执**,
// 库越大这一串越慢。用默认 15s 会在库已经换掉之后把请求判成超时,
// 操作员看到「未执行」而重试,等于把刚恢复好的库再覆盖一遍,所以单独放大超时。
// 幂等(X-Request-Id)照旧:现场断网重试时,重复请求不能把库换两遍。
restore: (input) =>
http.request<BackupRestoreResult>('/backup/restore', {
method: 'POST',
body: input,
timeoutMs: RESTORE_TIMEOUT_MS,
}),
// request() 会把 {ok,data} 拆开,这里拿到的是 data 本体,再按字段归一化。
network: async () => readNetworkInfo(await http.request<unknown>('/system/network')),
}
this.projects = createProjectsApi(http)
this.uploads = {
upload: (file) => {
const form = new FormData()
form.append('file', file)
return http.request<UploadResult>('/uploads', { method: 'POST', body: form })
},
}
}
/** 健康检查不走 {ok,data} 包装,直接返回 `{ok,version}`(05 第 7 节)。 */
async health(): Promise<HealthInfo> {
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<T>(path: string, options: RequestOptions = {}): Promise<T> {
const body = await this.requestJson(path, options)
return this.unwrap<T>(body)
}
private async requestVoid(path: string, options: RequestOptions = {}): Promise<void> {
const body = await this.requestJson(path, options)
// 204 或空响应视为成功;有响应体时仍校验 {ok:true}。
if (body === null || body === undefined) return
this.unwrap<unknown>(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<unknown> {
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<string> {
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。
*
* 不能直接把下载地址塞进 <a href>:服务端拒绝下载时回的是一份 JSON 错误体,
* 浏览器会把它当文件存下来,现场看到的是一个「下载成功了的报错文件」。
*/
private async requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
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<Response> {
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<string, string> = { 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<T>(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<unknown> {
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)
}