Files
Scoreboard/web/src/shared/api.ts
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

962 lines
36 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 { isRawBody, isRecord, readString } from './internal'
import { normalizeRulePack } from './rules'
import { parseYaml } from './yaml'
import type {
AdjustmentInput,
ApiErrorCode,
Athlete,
AthleteInput,
AthleteStanding,
AuditLog,
BackupResult,
CurrentUser,
DisplayCommand,
EntryHeatPayload,
EntryHeatResult,
EntryImportResult,
EntryWithResult,
Event,
EventCategory,
EventCategoryInput,
EventInput,
EventSettings,
HealthInfo,
ImportConflictDecision,
LiveEvents,
Page,
PageQuery,
PointAdjustment,
ProjectActivateResult,
ProjectInfo,
PublishPayload,
RecordBreak,
RecordBreakInput,
RecordEntry,
Result,
ResultQuery,
RulePack,
RuleSwitches,
SaveDraftPayload,
ScoreChannel,
ScoreChannelInput,
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
}
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',
]
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>
}
export interface EventsApi extends CrudApi<Event, EventInput> {
entries(eventId: number): Promise<EntryWithResult[]>
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> {
/**
* 彻底删除(`DELETE /api/teams/{id}?purge=1`)。
*
* 注意口径与 AthletesApi.purge 不同:这里是「假删除」——只让该单位从默认列表里消失,
* 报名、成绩、积分流水与人工调整一行未动,积分榜不变,也不会有任何重算。
* 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。
*/
purge(id: number): Promise<void>
}
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>
}
/** 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[]>
void(id: number, payload: VoidResultPayload): 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/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<void>
/** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */
instances(): Promise<DisplayInstance[]>
/** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */
live(): Promise<LiveEvents>
}
export interface ExportQuery {
eventId?: number
teamId?: number
}
export interface ExportApi {
/** 返回可直接放进 <a download> 的地址,省掉一次内存中的 Blob。 */
standingsUrl(query?: ExportQuery): string
standings(query?: ExportQuery): Promise<Blob>
save(blob: Blob, filename: string): void
}
export interface AuditApi {
list(query?: PageQuery & { entityType?: string; userId?: number }): Promise<Page<AuditLog>>
}
export interface RulesApi {
/** GET /api/rules/export,返回当前规则包。 */
get(): Promise<RulePack>
/** GET /api/rules/switches,recorder 可读的布尔开关(不含规则内容)。 */
switches(): Promise<RuleSwitches>
}
export interface SystemApi {
backup(): Promise<BackupResult>
}
/**
* 赛事项目切换。
*
* 服务端一次只打开一个赛事项目(启动参数 -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>
url(path: string, query?: object): string
blob(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'),
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;和运动员不同,这里是空响应。
purge: (id) => http.requestVoid(`/teams/${id}`, { method: 'DELETE', query: { purge: 1 } }),
}
}
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 },
}),
),
}
}
function createEventsApi(http: Http): EventsApi {
return {
...createCrudApi<Event, EventInput>(http, '/events'),
entries: (eventId) => http.request<EntryWithResult[]>(`/events/${eventId}/entries`),
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 }),
void: (id, payload) => http.request<Result>(`/results/${id}/void`, { method: 'POST', body: payload }),
}
}
function createExportApi(http: Http): ExportApi {
// 服务端只注册了 /export/standings.xlsx 与 /export/all.xlsx 等;
// 单位明细 / 分项成绩导出分别是 /export/teams/{id}、/export/events/{id},由各自页面拼地址。
const standingsPath = '/export/standings.xlsx'
return {
standingsUrl: (query) => http.url(standingsPath, query),
standings: (query) => http.blob(standingsPath, { query }),
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']
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 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)}`
}
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),
url: (path, query) => this.buildUrl(path, query),
blob: (path, requestOptions) => this.fetchBlob(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 }),
}
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' }),
}
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)
},
live: () => http.request<LiveEvents>('/display/live'),
}
this.exports = createExportApi(http)
this.auditLogs = {
list: (query) => http.request<Page<AuditLog>>('/audit-logs', { query }),
}
this.rules = createRulesApi(http)
this.system = {
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
}
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()
}
private async fetchBlob(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'
const payload = this.prepareBody(method, options.body)
// 断言:二进制 / 表单载荷只能原样透传,一旦被归一化展开就会静默丢内容(报名表导入踩过)。
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
headers['X-Request-Id'] = this.newRequestId()
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.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', `请求超时(${this.timeoutMs}ms)`, 0, error)
}
throw new ApiError('NETWORK', '无法连接服务端', 0, error)
} finally {
clearTimeout(timer)
}
}
/** 写操作注入 requestId:网络抖动重发时服务端能返回首次结果而不是重复执行。 */
private prepareBody(method: HttpMethod, body: unknown): unknown {
if (method === 'GET' || body === undefined) return undefined
// FormData/Blob/URLSearchParams 不能展开,否则文件/二进制内容会被丢掉。
if (isRawBody(body)) return body
if (isRecord(body) && !('requestId' in body)) {
return { ...body, requestId: this.newRequestId() }
}
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)
}