Files
Scoreboard/web/src/shared/basedata.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

241 lines
7.6 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.
/**
* 报名名单与基础数据包(单位 / 大项 / 小项 / 组别)的 xlsx 导入导出。
*
* 这两组接口没进 `shared/api.ts` 的 ApiClient(那是全站共用的客户端,本任务不改它),
* 单独放一个模块,导出页与测试都从这里取。
*
* 错误处理与全站一致:只有连不上 / 超时才可能是「后端未就绪」,
* 403 / 400 / 409 这些是服务端的真实回答,必须原样抛出,否则会把
* 「没权限」「表头写错了」伪装成示例数据。
*/
import { ApiError } from './api'
import type { ApiErrorCode } from './types'
const BASE_URL = '/api'
/** 导出要等服务端现算 xlsx,比普通读接口慢。 */
const DOWNLOAD_TIMEOUT_MS = 60_000
/** 上传整包要读全部工作表再写库,弱网下更慢。 */
const UPLOAD_TIMEOUT_MS = 120_000
const API_ERROR_CODES: readonly string[] = [
'AUTH_REQUIRED',
'AUTH_FAILED',
'FORBIDDEN',
'VALIDATION_FAILED',
'CONFLICT',
'STATE_INVALID',
'RATE_LIMITED',
'INTERNAL',
'NETWORK',
'TIMEOUT',
'PARSE_FAILED',
]
export const ENTRIES_XLSX_FILENAME = '报名名单.xlsx'
export const BASEDATA_XLSX_FILENAME = '基础数据包.xlsx'
export interface DownloadFile {
blob: Blob
filename: string
}
/** 报名名单的筛选条件;不传的字段 = 该维度不筛。 */
export interface EntryExportFilter {
eventId?: number
categoryId?: number
teamId?: number
heat?: number
}
export interface BaseDataFieldDiff {
field: string
from: string
to: string
}
/** 一行差异:哪张表、第几行、新增还是更新、哪个字段从什么变成什么。 */
export interface BaseDataChange {
sheet: string
line: number
action: string
key: string
fields: BaseDataFieldDiff[]
}
/**
* 差异预览 / 导入结果。
* `errors` 是行级中文错误(形如「单位 第 3 行:单位编码必填」),有错误就不会写库里那一行。
*/
export interface BaseDataImportResult {
dryRun: boolean
added: number
updated: number
unchanged: number
errorCount: number
errors: string[]
changes: BaseDataChange[]
}
/** 报名名单导出的请求路径(筛选参数为空就不带 query)。 */
export function entriesExportPath(filter: EntryExportFilter = {}): string {
const params = new URLSearchParams()
for (const key of ['eventId', 'categoryId', 'teamId', 'heat'] as const) {
const value = filter[key]
if (value !== undefined) params.append(key, String(value))
}
const query = params.toString()
return `/export/entries.xlsx${query === '' ? '' : `?${query}`}`
}
/** 下载报名名单(xlsx)。 */
export function downloadEntriesXLSX(filter: EntryExportFilter = {}): Promise<DownloadFile> {
return download(entriesExportPath(filter), ENTRIES_XLSX_FILENAME)
}
/** 下载基础数据包(多 sheet 工作簿)。 */
export function downloadBaseDataXLSX(): Promise<DownloadFile> {
return download('/export/basedata.xlsx', BASEDATA_XLSX_FILENAME)
}
/**
* 上传基础数据包。
* `dryRun=true` 只算差异不写库;用户确认后再用 `dryRun=false` 提交同一个文件。
*/
export async function importBaseDataXLSX(
file: File,
options: { dryRun?: boolean } = {},
): Promise<BaseDataImportResult> {
const form = new FormData()
form.append('file', file)
if (options.dryRun === true) form.append('dryRun', '1')
const response = await request('/export/basedata/import', {
method: 'POST',
body: form,
// 浏览器自己带 multipart 边界,显式设 Content-Type 反而会漏掉 boundary。
headers: csrfHeader(),
}, UPLOAD_TIMEOUT_MS)
const body = await readBody(response)
const record = isRecord(body) ? body : {}
if (record.ok !== true) {
throw toApiError(body, response.status)
}
const data = isRecord(record.data) ? record.data : {}
return {
dryRun: data.dryRun === true,
added: readNumber(data, 'added') ?? 0,
updated: readNumber(data, 'updated') ?? 0,
unchanged: readNumber(data, 'unchanged') ?? 0,
errorCount: readNumber(data, 'errorCount') ?? 0,
errors: readStrings(data.errors),
changes: readChanges(data.changes),
}
}
// ---------------------------------------------------------------------------
// 底层
// ---------------------------------------------------------------------------
async function download(path: string, filename: string): Promise<DownloadFile> {
const response = await request(path, { method: 'GET' })
return { blob: await response.blob(), filename }
}
async function request(path: string, init: RequestInit, timeoutMs = DOWNLOAD_TIMEOUT_MS): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(`${BASE_URL}${path}`, {
credentials: 'include',
...init,
signal: controller.signal,
})
if (!response.ok) {
throw toApiError(await readBody(response), response.status)
}
return response
} catch (error) {
if (error instanceof ApiError) throw error
if (controller.signal.aborted) {
throw new ApiError('TIMEOUT', `请求超时(${timeoutMs}ms)`, 0, error)
}
throw new ApiError('NETWORK', '无法连接服务端', 0, error)
} finally {
clearTimeout(timer)
}
}
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 {
return text
}
}
function csrfHeader(): Record<string, string> {
if (typeof document === 'undefined') return {}
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
const raw = match?.[1]
if (raw === undefined) return {}
return { 'X-CSRF-Token': decodeURIComponent(raw) }
}
function toApiError(body: unknown, status: number): ApiError {
const record = isRecord(body) ? body : {}
const error = isRecord(record.error) ? record.error : {}
const code = typeof error.code === 'string' && API_ERROR_CODES.includes(error.code)
? (error.code as ApiErrorCode)
: 'INTERNAL'
const message = typeof error.message === 'string' && error.message !== ''
? error.message
: `请求失败(HTTP ${status})`
return new ApiError(code, message, status, error.detail)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readNumber(source: Record<string, unknown>, key: string): number | null {
const value = source[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function readStrings(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === 'string')
}
function readChanges(value: unknown): BaseDataChange[] {
if (!Array.isArray(value)) return []
const out: BaseDataChange[] = []
for (const item of value) {
if (!isRecord(item)) continue
const fields: BaseDataFieldDiff[] = []
if (Array.isArray(item.fields)) {
for (const field of item.fields) {
if (!isRecord(field)) continue
fields.push({
field: typeof field.field === 'string' ? field.field : '',
from: typeof field.from === 'string' ? field.from : '',
to: typeof field.to === 'string' ? field.to : '',
})
}
}
out.push({
sheet: typeof item.sheet === 'string' ? item.sheet : '',
line: readNumber(item, 'line') ?? 0,
action: typeof item.action === 'string' ? item.action : '',
key: typeof item.key === 'string' ? item.key : '',
fields,
})
}
return out
}