用户给出的 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。
962 lines
36 KiB
TypeScript
962 lines
36 KiB
TypeScript
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)
|
||
}
|