feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开): 一、分工作表报名模板(新增能力) - 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板, 一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。 - 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。 - 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。 - 单表 CSV/XLSX 导入路径不回归。 二、号码布判重(修误判) - 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。 - 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行; 号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。 - 团体行的号码布一并纳入查重。 三、自动备份的关停与落盘(修数据安全隐患) - export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限, 超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。 - vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件; Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。 - 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件, 避免半截 .db 被列进备份列表、进而被「一键恢复」选中。 文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
This commit is contained in:
1 parent
b74768b7d5
commit
1b8a37ff8c
32 files changed
+3302
-233
No files matched your search
@@ -5,6 +5,7 @@ import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { baseDataApi } from './baseDataApi'
|
||||
import { useBaseData } from './useBaseData'
|
||||
import { atSignNameError } from './entrySelection'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import type { BaseDataTabKey } from './tabs'
|
||||
|
||||
@@ -148,6 +149,12 @@ function validate(): boolean {
|
||||
if (name === '') rowError.name = '名称必填'
|
||||
else if (seen.has(name)) rowError.name = '组别名称重复'
|
||||
else seen.add(name)
|
||||
// 组别名会被拼进分工作表模板的工作表名(「小项名称@组别名称」),里头的 @ 要先拦下。
|
||||
// 与「名称必填 / 重复」并列写:几条问题都标在同一格上,现场改一次就行。
|
||||
if (name !== '') {
|
||||
const atSign = atSignNameError('组别名称', name)
|
||||
if (atSign !== null) rowError.name = atSign
|
||||
}
|
||||
|
||||
const multiplier = Number(row.multiplier)
|
||||
if (!Number.isFinite(multiplier) || multiplier <= 0 || multiplier > 100) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
summarizeDecisions,
|
||||
templateFileName,
|
||||
templateHeader,
|
||||
templateSheetFileName,
|
||||
type DecisionMap,
|
||||
type TemplateLanguage,
|
||||
} from './entrySelection'
|
||||
@@ -175,10 +176,38 @@ const HEADER_ZH = templateHeader('zh').join(' / ')
|
||||
function downloadTemplate(language: TemplateLanguage): void {
|
||||
// 带 BOM,Excel 打开才不会把中文表头识别成乱码。
|
||||
const blob = new Blob([`\uFEFF${buildTemplateCsv(language)}\n`], { type: 'text/csv;charset=utf-8' })
|
||||
saveBlob(blob, templateFileName(language))
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载「分工作表模板」:一个小项组别一张工作表,由服务端按当前赛事的小项与组别生成。
|
||||
*
|
||||
* 必须走 fetch 拿 Blob、不能用 <a href> 直连:服务端拒绝生成时回的是 JSON 错误体,
|
||||
* 直连会让浏览器把那份 JSON 存成文件(现场看到的是「下载成功了的报错文件」)。
|
||||
*/
|
||||
const downloadingTemplate = ref(false)
|
||||
const templateError = ref<string | null>(null)
|
||||
|
||||
async function downloadSheetTemplate(language: TemplateLanguage): Promise<void> {
|
||||
if (downloadingTemplate.value) return
|
||||
downloadingTemplate.value = true
|
||||
templateError.value = null
|
||||
try {
|
||||
const blob = await data.downloadEntriesTemplate(language)
|
||||
saveBlob(blob, templateSheetFileName(language))
|
||||
} catch (caught) {
|
||||
templateError.value = caught instanceof Error ? caught.message : '下载分工作表模板失败'
|
||||
} finally {
|
||||
downloadingTemplate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 Blob 存成文件;两种模板共用。 */
|
||||
function saveBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = templateFileName(language)
|
||||
anchor.download = filename
|
||||
anchor.rel = 'noopener'
|
||||
anchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
@@ -197,7 +226,7 @@ function downloadTemplate(language: TemplateLanguage): void {
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm template-button"
|
||||
type="button"
|
||||
title="表头为中文列名(项目编码 / 组别 / 单位编码…),给习惯看中文的填表人用;文件名 entries-template-zh.csv"
|
||||
title="单表 CSV 模板:一份表装下全部报名,表头为中文列名(项目编码 / 组别 / 单位编码…),给习惯看中文的填表人用;文件名 entries-template-zh.csv"
|
||||
@click="downloadTemplate('zh')"
|
||||
>
|
||||
<MxIcon name="export" :size="14" /> 中文表头
|
||||
@@ -205,14 +234,36 @@ function downloadTemplate(language: TemplateLanguage): void {
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary mx-btn-sm template-button"
|
||||
type="button"
|
||||
title="表头为英文列名(event_code / category / team_code…),给沿用老模板的填表人用;文件名 entries-template-en.csv"
|
||||
title="单表 CSV 模板:一份表装下全部报名,表头为英文列名(event_code / category / team_code…),给沿用老模板的填表人用;文件名 entries-template-en.csv"
|
||||
@click="downloadTemplate('en')"
|
||||
>
|
||||
<MxIcon name="export" :size="14" /> 英文表头
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm template-button"
|
||||
type="button"
|
||||
:disabled="downloadingTemplate || !data.canWrite.value"
|
||||
title="分工作表 XLSX 模板:按当前赛事已配好的小项 × 组别各建一张工作表(表名「小项名称@组别名称」),表头为中文列名;一个小项组别一张表,填起来不容易串行"
|
||||
@click="downloadSheetTemplate('zh')"
|
||||
>
|
||||
<MxIcon name="export" :size="14" /> 分工作表 · 中文表头
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary mx-btn-sm template-button"
|
||||
type="button"
|
||||
:disabled="downloadingTemplate || !data.canWrite.value"
|
||||
title="分工作表 XLSX 模板:按当前赛事已配好的小项 × 组别各建一张工作表(表名「小项名称@组别名称」),表头为英文列名;一个小项组别一张表,填起来不容易串行"
|
||||
@click="downloadSheetTemplate('en')"
|
||||
>
|
||||
<MxIcon name="export" :size="14" /> 分工作表 · 英文表头
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="templateError !== null" class="mx-badge mx-badge-error import-error template-error">
|
||||
{{ templateError }}
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<div class="bd-warn">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
@@ -222,6 +273,11 @@ function downloadTemplate(language: TemplateLanguage): void {
|
||||
两套模板列数与列顺序完全一样,只有表头语言不同,按手上表格的样子下载即可;
|
||||
下载按钮上的「中文表头 / 英文表头」就是文件里的表头语言。
|
||||
同一列只能写一次:既有 event_code 又有「项目编码」会被拦下并提示,请删掉多余的那一列。
|
||||
<br />
|
||||
<strong>两种模板都能导入</strong>:<strong>单表模板</strong>(CSV,一份表装全部报名)与
|
||||
<strong>分工作表模板</strong>(XLSX,一个小项组别一张工作表,表名写成「小项名称@组别名称」)。
|
||||
分工作表模板的小项与组别由<strong>工作表名</strong>决定,「项目编码 / 组别」两列可以留空;
|
||||
填了就必须和表名一致,否则那一行会报错。小项与组别的名字里<strong>不能有 @</strong>(它是表名里的分隔符)。
|
||||
id_card、gender、age、heat 可缺省,
|
||||
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
|
||||
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
|
||||
@@ -243,7 +299,7 @@ function downloadTemplate(language: TemplateLanguage): void {
|
||||
<MxIcon name="export" :size="26" />
|
||||
<div class="drop-title">点击选择,或把 CSV / XLSX 拖到这里</div>
|
||||
<div class="bd-note">
|
||||
单个文件不超过 32MB;xlsx 由服务端解析第一个工作表;CSV 直接用 Excel「另存为 CSV」即可(UTF-8、带 BOM 的 UTF-8、GBK/GB18030、UTF-16 都能读,不用特意转码)
|
||||
单个文件不超过 32MB;CSV 与单表 XLSX 照旧(单表只读第一张工作表),分工作表模板按每张工作表名里的「小项名称@组别名称」定范围。CSV 直接用 Excel「另存为 CSV」即可(UTF-8、带 BOM 的 UTF-8、GBK/GB18030、UTF-16 都能读,不用特意转码)
|
||||
</div>
|
||||
<input ref="fileInput" class="file-input" type="file" accept=".csv,.xlsx" @change="onPick" />
|
||||
</div>
|
||||
@@ -426,7 +482,7 @@ function downloadTemplate(language: TemplateLanguage): void {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 两个下载按钮并排、颜色不同、文案带「中文表头 / 英文表头」:现场人多手快,不能点错。 */
|
||||
/* 下载按钮并排、颜色不同、文案带「中文表头 / 英文表头」:现场人多手快,不能点错。 */
|
||||
.template-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -439,6 +495,10 @@ function downloadTemplate(language: TemplateLanguage): void {
|
||||
.template-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 模板下载失败的说明跟导入错误同一处样式,但独立成块:它和「这次导入」没关系。 */
|
||||
.template-error {
|
||||
margin: 0 16px 12px;
|
||||
}
|
||||
.drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -25,6 +25,7 @@ import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import FormDialog from './FormDialog.vue'
|
||||
import FormField from './FormField.vue'
|
||||
import { useBaseData } from './useBaseData'
|
||||
import { atSignNameError } from './entrySelection'
|
||||
import { EVENT_ROW_DBLCLICK_TITLE, openEventCategories } from './eventRowActions'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import type { BaseDataTabKey } from './tabs'
|
||||
@@ -383,6 +384,11 @@ function validateEvent(): boolean {
|
||||
next.code = '代码已存在,必须唯一'
|
||||
}
|
||||
if (eventForm.value.name.trim() === '') next.name = '名称必填'
|
||||
else {
|
||||
// 小项名会被拼进分工作表模板的工作表名(「小项名称@组别名称」),里头的 @ 要先拦下。
|
||||
const atSign = atSignNameError('小项名称', eventForm.value.name.trim())
|
||||
if (atSign !== null) next.name = atSign
|
||||
}
|
||||
const multiplier = Number(eventForm.value.multiplier)
|
||||
if (!Number.isFinite(multiplier) || multiplier <= 0 || multiplier > 100) next.multiplier = '权重为 0–100 的正数'
|
||||
const sortOrder = Number(eventForm.value.sortOrder)
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
TeamInput,
|
||||
} from '@/shared'
|
||||
import { mockBaseData } from './mockBaseData'
|
||||
import { TEMPLATE_SHEET_MOCK_HINT, type TemplateLanguage } from './entrySelection'
|
||||
|
||||
/**
|
||||
* 基础数据接口入口。
|
||||
@@ -225,5 +226,20 @@ export const baseDataApi = {
|
||||
() => adminApi.client.entries.resolveImport(file, decisions),
|
||||
() => mockBaseData.resolveImportConflicts(file, decisions),
|
||||
),
|
||||
/**
|
||||
* 分工作表报名模板(xlsx):服务端按当前赛事的「小项 × 组别」逐张建表。
|
||||
*
|
||||
* 与读接口一样允许降级到示例数据:服务端没起来时示例数据里没有真的 xlsx 可下,
|
||||
* 就明确回一句「连上服务端再试」(见 TEMPLATE_SHEET_MOCK_HINT),
|
||||
* 而不是给一份假的下载文件——那会让人拿着空模板去发通知。
|
||||
* 业务错误(名字里带 @、还没有小项组别)原样抛出,页面直接把服务端那句话显示出来。
|
||||
*/
|
||||
downloadTemplate: (language: TemplateLanguage): Promise<Blob> =>
|
||||
call(
|
||||
() => adminApi.client.entries.downloadTemplate(language),
|
||||
() => {
|
||||
throw new Error(TEMPLATE_SHEET_MOCK_HINT)
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -146,6 +146,37 @@ export function templateFileName(language: TemplateLanguage): string {
|
||||
return `entries-template-${language}.csv`
|
||||
}
|
||||
|
||||
/**
|
||||
* 分工作表模板(xlsx)的文件名:与单表 CSV 模板同一个命名口径,后缀区分成 xlsx。
|
||||
*
|
||||
* 两种模板**不是二选一**:单表 CSV 照样能用(老填表人不用改习惯),分工作表模板只是多了一种
|
||||
* 「一个小项组别一张表」的组织方式,见 docs/报名名单导入规范.md。
|
||||
*/
|
||||
export function templateSheetFileName(language: TemplateLanguage): string {
|
||||
return `entries-template-${language}.xlsx`
|
||||
}
|
||||
|
||||
/**
|
||||
* 小项名 / 组别名里出现 @ 的说明,没出现时返回 null。
|
||||
*
|
||||
* 与服务端 atSignNameMessage(entries_sheet_template.go)说的是同一件事、同一个理由:
|
||||
* @ 是分工作表报名模板里「小项名称@组别名称」的分隔符,名字里再出现它,工作表名就拆不开。
|
||||
* 只说「不能含 @」现场会以为是输入法问题,一定要把「为什么」写出来。
|
||||
*/
|
||||
export function atSignNameError(kind: string, name: string): string | null {
|
||||
if (!name.includes('@') && !name.includes('@')) return null
|
||||
return `${kind}「${name}」里不能有 @:@ 是分工作表模板中小项与组别的分隔符(表名要写成「小项名称@组别名称」)`
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例数据模式下点「分工作表模板」的提示。
|
||||
*
|
||||
* 分工作表工作簿要按当前赛事的小项 × 组别逐张建表,浏览器里造不出 xlsx(也不该为此引一个库),
|
||||
* 所以这里明确说「连上服务端才有」,而不是给一份假的下载文件。
|
||||
*/
|
||||
export const TEMPLATE_SHEET_MOCK_HINT =
|
||||
'示例数据模式无法生成分工作表模板:它由服务端按当前赛事的小项与组别逐张建表。请连接服务端后重试,或继续用单表 CSV 模板'
|
||||
|
||||
/** 模板里的示例行:两种语言共用,只有表头不同。 */
|
||||
const TEMPLATE_ROWS: readonly string[][] = [
|
||||
['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', ''],
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useAuthStore } from '../../stores/auth'
|
||||
import { useEventStore } from '../../stores/event'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import { baseDataApi, baseDataFallback } from './baseDataApi'
|
||||
import type { TemplateLanguage } from './entrySelection'
|
||||
|
||||
/**
|
||||
* 基础数据页的共享状态。
|
||||
@@ -251,6 +252,16 @@ function resolveImportConflicts(file: File, decisions: ImportConflictDecision[])
|
||||
return baseDataApi.entries.resolveImportConflicts(file, decisions)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载「按小项 × 组别分工作表」的报名模板(xlsx)。
|
||||
*
|
||||
* 由服务端按当前赛事的小项与组别生成,language 只决定表头语言(与单表 CSV 模板的两个按钮同口径)。
|
||||
* 这里不吞错误:名字里带 @、还没有小项组别,服务端都会回一句能照着改的中文说明,页面直接显示。
|
||||
*/
|
||||
function downloadEntriesTemplate(language: TemplateLanguage): Promise<Blob> {
|
||||
return baseDataApi.entries.downloadTemplate(language)
|
||||
}
|
||||
|
||||
// ── 榜单口径(全局赛事设置) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -341,6 +352,8 @@ export interface BaseDataController {
|
||||
purgeAthlete: (id: number) => Promise<AthletePurgeResult>
|
||||
importEntries: (file: File) => Promise<EntryImportResult>
|
||||
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
|
||||
/** 分工作表报名模板(xlsx):服务端按当前赛事的小项 × 组别生成,lang 只决定表头语言。 */
|
||||
downloadEntriesTemplate: (language: TemplateLanguage) => Promise<Blob>
|
||||
/** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */
|
||||
saveIndividualToTeamWeight: (weight: number) => Promise<EventSettings>
|
||||
/** 只改「分批发布是否实时更新榜单」;保存后同步 event store。 */
|
||||
@@ -407,6 +420,7 @@ export function useBaseData(): BaseDataController {
|
||||
purgeAthlete,
|
||||
importEntries,
|
||||
resolveImportConflicts,
|
||||
downloadEntriesTemplate,
|
||||
saveIndividualToTeamWeight,
|
||||
saveLiveOnPublish,
|
||||
}
|
||||
|
||||
@@ -390,15 +390,29 @@ test('缺列提示中英并列,且点到表头行', () => {
|
||||
assert.match(message, /中文与英文表头都认/)
|
||||
})
|
||||
|
||||
// ImportSection 的两个下载按钮:现场人多手快,文案必须一眼能分(SFC 跑不进 node --test,
|
||||
// ImportSection 的四个下载按钮:现场人多手快,文案必须一眼能分(SFC 跑不进 node --test,
|
||||
// 这里用源码契约断言接线:按钮确实在、点了确实按语言下载)。
|
||||
test('ImportSection 有两个模板下载按钮,分别下载中英表头', () => {
|
||||
//
|
||||
// 两种模板:单表 CSV(「中文表头 / 英文表头」,前端本地拼)与分工作表 XLSX
|
||||
// (「分工作表 · 中文表头 / 分工作表 · 英文表头」,由服务端按当前小项与组别生成)。
|
||||
test('ImportSection 有四个模板下载按钮:单表两套表头 + 分工作表两套表头', () => {
|
||||
const source = readFileSync(new URL('../components/baseData/ImportSection.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*中文表头\s*</, '有「中文表头」按钮')
|
||||
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*英文表头\s*</, '有「英文表头」按钮')
|
||||
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*分工作表 · 中文表头\s*</, '有「分工作表 · 中文表头」按钮')
|
||||
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*分工作表 · 英文表头\s*</, '有「分工作表 · 英文表头」按钮')
|
||||
assert.match(source, /downloadTemplate\('zh'\)/)
|
||||
assert.match(source, /downloadTemplate\('en'\)/)
|
||||
assert.match(source, /anchor\.download = templateFileName\(language\)/, '文件名按语言取,两个文件不重名')
|
||||
assert.match(source, /downloadSheetTemplate\('zh'\)/)
|
||||
assert.match(source, /downloadSheetTemplate\('en'\)/)
|
||||
assert.match(source, /saveBlob\(blob, templateFileName\(language\)\)/, '单表文件名按语言取,两个文件不重名')
|
||||
assert.match(source, /saveBlob\(blob, templateSheetFileName\(language\)\)/, '分工作表文件名按语言取')
|
||||
assert.match(source, /buildTemplateCsv\(language\)/)
|
||||
// 分工作表模板必须走服务端(浏览器里造不出多工作表 xlsx),且失败时要把服务端那句话显示出来。
|
||||
assert.match(source, /data\.downloadEntriesTemplate\(language\)/)
|
||||
assert.match(source, /templateError\.value = caught instanceof Error \? caught\.message : /)
|
||||
assert.doesNotMatch(source, /entries-template\.csv/, '不再用不分语言的老文件名')
|
||||
// 分工作表模板由服务端生成、以 Blob 交回本地存盘:不能把接口地址直接塞进 <a href>
|
||||
// (服务端拒绝时回的是 JSON 错误体,浏览器会把那份 JSON 存成一个「下载成功」的文件)。
|
||||
assert.match(source, /URL\.createObjectURL\(blob\)/)
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
atSignNameError,
|
||||
templateFileName,
|
||||
templateSheetFileName,
|
||||
} from '../components/baseData/entrySelection'
|
||||
|
||||
/**
|
||||
* 分工作表模板(一个小项 × 一个组别一张工作表)在前端这一侧的三件事:
|
||||
* 表名里的 @ 为什么不能出现在小项 / 组别名里(界面校验的文案)、下载文件名、
|
||||
* 以及下载走的是哪个接口、服务端拒绝时错误怎么回到页面。
|
||||
*
|
||||
* 服务端那一半(生成工作簿、按表名解析)在 server/internal/httpapi 的
|
||||
* entries_sheet_template_test.go 里钉着;这里钉的是「界面拦住 + 请求发对」。
|
||||
*/
|
||||
|
||||
// 必须在导入被测模块之前装好 fetch:ApiClient 构造时就把当时的 fetch 绑进实例。
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
let calls: string[] = []
|
||||
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
|
||||
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
calls.push(String(input))
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
const { ApiError } = await import('@/shared')
|
||||
const { createApiClient } = await import('@/shared')
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
const { mockMode, mockReason } = await import('../api/client')
|
||||
const { TEMPLATE_SHEET_MOCK_HINT } = await import('../components/baseData/entrySelection')
|
||||
|
||||
test('小项名 / 组别名里出现 @ 时给出带理由的提示;没有 @ 时不拦', () => {
|
||||
assert.equal(atSignNameError('小项名称', '100米跑'), null)
|
||||
assert.equal(atSignNameError('组别名称', ''), null)
|
||||
|
||||
const event = atSignNameError('小项名称', '100米@跑')
|
||||
assert.ok(event !== null)
|
||||
assert.match(event, /小项名称「100米@跑」/)
|
||||
// 「为什么」必须写出来:只说「不能含 @」现场会以为是输入法问题。
|
||||
assert.match(event, /分隔符/)
|
||||
assert.match(event, /小项名称@组别名称/)
|
||||
|
||||
const category = atSignNameError('组别名称', '男子@甲组')
|
||||
assert.ok(category !== null)
|
||||
assert.match(category, /组别名称「男子@甲组」/)
|
||||
assert.match(category, /分隔符/)
|
||||
|
||||
// 全角 @ 与半角 @ 在表名里肉眼分不出,一并拦。
|
||||
assert.ok(atSignNameError('小项名称', '男子@甲组') !== null)
|
||||
})
|
||||
|
||||
test('两种模板的文件名分得清:csv 是单表模板、xlsx 是分工作表模板', () => {
|
||||
assert.equal(templateFileName('zh'), 'entries-template-zh.csv')
|
||||
assert.equal(templateFileName('en'), 'entries-template-en.csv')
|
||||
assert.equal(templateSheetFileName('zh'), 'entries-template-zh.xlsx')
|
||||
assert.equal(templateSheetFileName('en'), 'entries-template-en.xlsx')
|
||||
})
|
||||
|
||||
test('下载分工作表模板走 GET /entries/template.xlsx 并带 lang', async () => {
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
fetchImpl: async (input) => {
|
||||
calls.push(String(input))
|
||||
return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
calls = []
|
||||
const blob = await client.entries.downloadTemplate('en')
|
||||
|
||||
assert.deepEqual(calls, ['/api/entries/template.xlsx?lang=en'])
|
||||
assert.equal(blob.size, 4)
|
||||
})
|
||||
|
||||
test('服务端拒绝生成时,错误文案原样回到页面(不是一份存下来的报错文件)', async () => {
|
||||
const client = createApiClient({
|
||||
baseUrl: '/api',
|
||||
fetchImpl: async () =>
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'CONFLICT',
|
||||
message: '当前赛事有 1 处小项 / 组别的名字里带 @(或全角 @)……请先改名,再重新下载模板',
|
||||
},
|
||||
},
|
||||
409,
|
||||
),
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
() => client.entries.downloadTemplate('zh'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.status, 409)
|
||||
assert.match(error.message, /带 @/)
|
||||
assert.match(error.message, /改名/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('服务端不可达时不产出假模板:回一句「连上服务端再试」,并切到示例数据模式', async () => {
|
||||
mockMode.value = false
|
||||
mockReason.value = null
|
||||
baseDataFallback.value = false
|
||||
calls = []
|
||||
reply = () => {
|
||||
throw new TypeError('fetch failed')
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => baseDataApi.entries.downloadTemplate('zh'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error)
|
||||
assert.equal(error.message, TEMPLATE_SHEET_MOCK_HINT)
|
||||
assert.match(error.message, /示例数据/)
|
||||
assert.match(error.message, /服务端/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(baseDataFallback.value, true, '连不上时应切到示例数据模式')
|
||||
|
||||
mockMode.value = false
|
||||
mockReason.value = null
|
||||
baseDataFallback.value = false
|
||||
})
|
||||
|
||||
test('服务端接口尚未实现(404)时同样不产出假模板,提示与上面同一句', async () => {
|
||||
mockMode.value = false
|
||||
baseDataFallback.value = false
|
||||
reply = () => jsonResponse({ ok: false, error: { code: 'NOT_FOUND', message: '接口不存在' } }, 404)
|
||||
|
||||
await assert.rejects(
|
||||
() => baseDataApi.entries.downloadTemplate('en'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error)
|
||||
assert.equal(error.message, TEMPLATE_SHEET_MOCK_HINT)
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(baseDataFallback.value, true, '接口未实现时页面应切到示例数据模式')
|
||||
|
||||
baseDataFallback.value = false
|
||||
})
|
||||
@@ -312,6 +312,14 @@ export interface EntriesApi {
|
||||
* 只改录入分组,不影响任何成绩与积分。
|
||||
*/
|
||||
setHeat(payload: EntryHeatPayload): Promise<EntryHeatResult>
|
||||
/**
|
||||
* 「按小项 × 组别分工作表」的报名模板(xlsx),服务端按当前赛事的基础数据生成。
|
||||
*
|
||||
* language 只决定表头语言(zh 用中文列名、en 用英文规范名),与单表 CSV 模板的两个按钮同一套口径。
|
||||
* 服务端还会拒绝生成:名字里带 @ 的小项 / 组别、或者还没有任何「小项 + 组别」组合时,
|
||||
* 回的是一份 {ok:false} 的说明,调用方按普通 ApiError 展示即可。
|
||||
*/
|
||||
downloadTemplate(language: 'zh' | 'en'): Promise<Blob>
|
||||
}
|
||||
|
||||
/** POST /api/uploads 的响应:path 是服务端存储后的路径,url 与其相同,可直接放进 <img src>。 */
|
||||
@@ -470,6 +478,7 @@ interface Http {
|
||||
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> {
|
||||
@@ -876,6 +885,7 @@ export class ApiClient {
|
||||
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 = {
|
||||
@@ -919,6 +929,8 @@ export class ApiClient {
|
||||
},
|
||||
setHeat: (payload) =>
|
||||
http.request<EntryHeatResult>('/entries/heat', { method: 'PUT', body: payload }),
|
||||
downloadTemplate: (language) =>
|
||||
http.requestBlob('/entries/template.xlsx', { query: { lang: language }, timeoutMs: 60_000 }),
|
||||
}
|
||||
|
||||
this.results = createResultsApi(http)
|
||||
@@ -1055,6 +1067,26 @@ export class ApiClient {
|
||||
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 共用它。
|
||||
|
||||
Reference in new issue
Block a user