Files
Scoreboard/web/src/admin/pages/entryBatch.ts
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

280 lines
11 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 { ApiError } from '@/shared'
import type { ApiErrorCode, EntryStatus } from '@/shared'
/**
* 报名项批量操作(退赛 / 恢复 / 移出报名)的前端逻辑。
*
* 网络层直接 fetch 而不是走 adminApi:client.ts 的接口表与 shared/api.ts 归口在共享层,
* 本功能只服务录入页,先把请求构造与确认文案收在这一个模块里,便于单测;
* CSRF 与 X-Request-Id 的补法与 shared/api.ts 的写请求保持一致,否则写操作会被 403 挡回。
*/
/** 与 server/internal/httpapi/entries_batch.go 的 maxEntryBatch 一致。 */
export const MAX_ENTRY_BATCH = 500
export const ENTRY_BATCH_PATH = '/api/entries/batch'
/** 确认框里逐条列出「将被删的人」时最多列几行,超出的只报数。 */
const MAX_LISTED_PEOPLE = 20
export type EntryBatchAction = 'scratch' | 'restore' | 'remove'
export interface EntryBatchPayload {
action: EntryBatchAction
ids: number[]
reason?: string
requestId?: string
}
export interface EntryBatchItem {
id: number
ok: boolean
error?: string
skipped?: boolean
note?: string
}
export interface EntryBatchSummary {
ok: number
failed: number
skipped: number
changed: number
removedEntries: number
removedResults: number
removedLedger: number
removedBreaks: number
recomputedEvents: number[]
}
export interface EntryBatchResult {
items: EntryBatchItem[]
summary: EntryBatchSummary
}
/** 批量操作作用于的勾选行;字段只取确认文案与跳过判定需要的部分。 */
export interface EntryBatchRow {
entryId: number
/** 展示名:运动员姓名 → 队员首人 → 单位(与表格行文案同口径)。 */
name: string
bib: string | null
team: string
categoryId: number
entryStatus: EntryStatus
}
export interface EntryBatchPlan {
action: EntryBatchAction
/** 去重后的报名项 id(服务端同样会去重,这里先去掉重复勾选)。 */
ids: number[]
/** 真正会被改动的行数。 */
changed: number
/** 会被跳过的行及其原因(dq / dns 是别人的判定,不覆盖)。 */
skipped: { name: string; note: string }[]
/** 会重算的组别数量:重算按 (小项, 组别) 走,本页只有当前小项。 */
scopeCount: number
message: string
details: string[]
}
/**
* 去重并丢掉非法 id(0 / 负数 / 非整数),保持勾选顺序。
* 与服务端 dedupeIDs 同一口径:重复勾选不该变成两条请求明细。
*/
export function dedupeEntryIds(ids: number[]): number[] {
const out: number[] = []
const seen = new Set<number>()
for (const id of ids) {
if (!Number.isInteger(id) || id <= 0 || seen.has(id)) continue
seen.add(id)
out.push(id)
}
return out
}
/** 批量条数校验;通过返回 null,否则返回可直接上屏的中文提示。 */
export function entryBatchSizeError(count: number): string | null {
if (count === 0) return '请先勾选要操作的报名项'
if (count > MAX_ENTRY_BATCH) {
return `一次最多处理 ${MAX_ENTRY_BATCH} 条报名项,请分批提交`
}
return null
}
/** 该行在当前动作下会不会被改动;不改动时返回跳过原因(与服务端文案一致)。 */
export function entryBatchSkipNote(action: EntryBatchAction, status: EntryStatus): string | null {
if (action === 'remove') return null
if (action === 'scratch' && status === 'registered') return null
if (action === 'restore' && status === 'scratched') return null
switch (status) {
case 'scratched':
return '已是退赛状态,跳过'
case 'registered':
return '当前正常参赛,无需恢复'
case 'dq':
return '已被判罚取消资格(dq),跳过,不覆盖原判定'
case 'dns':
return '已标记未出场(dns),跳过,不覆盖原判定'
}
return `当前状态为「${status}」,跳过`
}
/** 行在确认框里的身份行:姓名|号码布|单位。 */
function personLine(row: EntryBatchRow): string {
return `${row.name || '(未署名)'}|号码 ${row.bib ?? '—'}|${row.team || '未知单位'}`
}
/**
* 把一次批量操作整理成确认框能直接用的内容:
* 前端先算清「动谁、跳过谁、重算几个组别」,不让操作员点完才知道。
*/
export function planEntryBatch(action: EntryBatchAction, rows: EntryBatchRow[]): EntryBatchPlan {
const ids = dedupeEntryIds(rows.map((row) => row.entryId))
// 重复勾选不该在「动几个人」里数两遍:按 entryId 去重后再统计。
const seen = new Set<number>()
const unique = rows.filter((row) => {
if (seen.has(row.entryId)) return false
seen.add(row.entryId)
return true
})
const scopes = new Set(unique.map((row) => row.categoryId))
const changedRows: EntryBatchRow[] = []
const skipped: { name: string; note: string }[] = []
for (const row of unique) {
const note = entryBatchSkipNote(action, row.entryStatus)
if (note === null) changedRows.push(row)
else skipped.push({ name: row.name || '(未署名)', note })
}
const changed = changedRows.length
const scopeCount = scopes.size
const details: string[] = []
if (action === 'remove') {
details.push(`将删除以下 ${changed} 人(物理删除,不可恢复):`)
for (const row of changedRows.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${personLine(row)}`)
if (changedRows.length > MAX_LISTED_PEOPLE) {
details.push(`…还有 ${changedRows.length - MAX_LISTED_PEOPLE} 人未列出`)
}
} else if (skipped.length > 0) {
details.push(`以下 ${skipped.length} 人会被跳过(不覆盖已有判定):`)
for (const item of skipped.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${item.name}:${item.note}`)
if (skipped.length > MAX_LISTED_PEOPLE) {
details.push(`…还有 ${skipped.length - MAX_LISTED_PEOPLE} 人未列出`)
}
}
let message = ''
if (action === 'scratch') {
message = `将把选中的 ${changed} 人标记为退赛(0 分且不占名次),随后按新名次重算积分榜。退赛随时可以用「批量恢复」撤回。`
} else if (action === 'restore') {
message = `将把选中的 ${changed} 人恢复为正常参赛,随后按新名次重算积分榜。`
} else {
message = `将从报名名单中物理删除选中的 ${changed} 人,成绩与积分流水一并删除,不可恢复。`
}
// 本页只操作当前小项;重算按 (小项, 组别) 走,所以组别数就是这里的规模。
if (scopeCount > 0) {
details.push(`会重算 1 个小项(当前项目)、${scopeCount} 个组别的名次与积分。`)
}
if (action === 'remove' && changed > MAX_LISTED_PEOPLE) {
message += `这会重算 1 个小项(当前项目)、${scopeCount} 个组别,别人的名次会变。`
}
return { action, ids, changed, skipped, scopeCount, message, details }
}
/** 批量操作成功后的 toast 副标题:逐条成败 + 删除规模 + 重算范围。 */
export function entryBatchOutcome(result: EntryBatchResult): string {
const { summary } = result
const parts: string[] = []
parts.push(`成功 ${summary.ok} 条${summary.skipped > 0 ? `(跳过 ${summary.skipped} 条)` : ''}`)
if (summary.failed > 0) parts.push(`失败 ${summary.failed} 条`)
if (summary.removedEntries > 0) parts.push(`删除报名 ${summary.removedEntries} 条`)
if (summary.removedResults > 0) parts.push(`删除成绩 ${summary.removedResults} 条`)
if (summary.removedLedger > 0) parts.push(`删除流水 ${summary.removedLedger} 条`)
if (summary.recomputedEvents.length > 0) parts.push(`重算 ${summary.recomputedEvents.length} 个小项`)
return parts.join(';')
}
function readCsrfToken(): string | null {
if (typeof document === 'undefined') return null
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
return match?.[1] === undefined ? null : decodeURIComponent(match[1])
}
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)}`
}
const KNOWN_CODES: readonly string[] = [
'AUTH_REQUIRED',
'AUTH_FAILED',
'FORBIDDEN',
'VALIDATION_FAILED',
'CONFLICT',
'STATE_INVALID',
'RATE_LIMITED',
'INTERNAL',
'NETWORK',
'TIMEOUT',
'PARSE_FAILED',
]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError {
const safe: ApiErrorCode = typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL'
return new ApiError(safe, message, status, detail)
}
/**
* 提交批量操作。写操作不降级到示例数据:示例数据模式下的「成功」会让操作员
* 以为已经退赛 / 删干净了,页面必须先看 data.isMock 再决定是否调用这里。
*/
export async function submitEntryBatch(payload: EntryBatchPayload): Promise<EntryBatchResult> {
// 一次调用只生成一个幂等键:header 供服务端 cached 层去重,body.requestId 是同一把钥匙。
// 两处各生成一个就永远命中不了缓存,超时重试「移出报名」会真的把同一批人删两遍。
const requestId = payload.requestId ?? defaultRequestId()
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'X-Request-Id': requestId,
}
const token = readCsrfToken()
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
let response: Response
try {
response = await fetch(ENTRY_BATCH_PATH, {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({ ...payload, requestId }),
})
} catch (caught) {
throw apiError('NETWORK', '无法连接服务端,批量操作没有生效', 0, caught)
}
let body: unknown
try {
body = await response.json()
} catch (caught) {
throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught)
}
if (!isRecord(body) || typeof body['ok'] !== 'boolean') {
throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body)
}
if (body['ok'] !== true) {
const error = isRecord(body['error']) ? body['error'] : {}
const message = typeof error['message'] === 'string' ? error['message'] : `批量操作失败(HTTP ${response.status})`
throw apiError(error['code'], message, response.status, error['detail'])
}
if (!response.ok) {
throw apiError('INTERNAL', `批量操作失败(HTTP ${response.status})`, response.status, body)
}
return body['data'] as EntryBatchResult
}