feat(import): 报名导入支持中文表头,并给出中/英两套模板下载

需求:主人要「CSV 模板的表头能改一下,或者提供两个按钮,一个中文表头一个英文表头」。

- 解析器加**中文别名**(英文仍是规范名:取值、报错、纯英文模板、勾选导出都用它)。
  12 列,顺序即模板列序:项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 /
  号码布 / 种子 / 批次 / 状态 / 队员名单;另认一批同义写法(小项编码、参赛单位编码、
  运动员姓名、身份证号、道次、批次号、报名状态…)。
  Go 别名表 == 前端 COLUMN_ALIASES == 导入规范文档表格,三方逐列逐名一致(脚本核对)。
- **向后兼容**:纯英文表头一字不改照常导入;回归用**字面量**(不复用常量)钉死老 11 列表头
  与大写/空格变体,避免有人改常量把回归一起改掉。
- **中英可任意混排**,但**同一列出现两种写法(或写两遍)整份拦下**并说明——两列都可能带数据,
  静默取其一等于丢另一列。容错沿用既有先例:BOM(含格子中间的 U+FEFF)、全角空格/全角 ASCII、
  首尾空格、大小写。
- 缺列报错**中英并列**(「缺少列 athlete_name(姓名)」),现场拿中文模板的人一眼看懂。
- 前端两个下载按钮:「下载模板(中文表头)」「下载模板(英文表头)」,列序与列数完全一致,
  文件名可区分;示例数据模式(mock)同步,避免两条路径产出不同模板。
- 文档 `docs/报名名单导入规范.md` 补两套表头与「两套都能导入」的说明(原内容未删)。

端到端(真服务端 + curl):中文模板填两行 → {"created":2} 且库中值取自中文列;
英文模板同一份数据 → {"updated":2} 不重复建档;缺列与表头重复分别给出上述人话报错。
自检:gofmt / go build / go vet / go test 全绿;vue-tsc + 前端 754 例全过。
This commit is contained in:
AskaEth committed 2026-09-13 21:55:37 +08:00
1 parent b1c6d5d54a
commit 9951a69938
8 files changed
+713 -53

No files matched your search

@@ -7,7 +7,7 @@ import { useBaseData } from './useBaseData'
import { entryImportSizeError } from './importLimits'
import { useToastStore } from '../../stores/toast'
import {
REQUIRED_COLUMNS,
buildTemplateCsv,
conflictDiffs,
conflictLabel,
decisionKeyOf,
@@ -15,20 +15,15 @@ import {
pruneDecisions,
recordDecisions,
summarizeDecisions,
templateFileName,
templateHeader,
type DecisionMap,
type TemplateLanguage,
} from './entrySelection'
const data = useBaseData()
const toast = useToastStore()
// heat 列是批次:留空 = 未分批,也可以直接填正整数(如 1、2)。
const TEMPLATE_CSV = [
REQUIRED_COLUMNS.join(','),
'M100,男子组,F1,张伟,110101199001011234,男,26,101,,1,registered,',
'W100,女子组,F2,王芳,,女,,202,,2,registered,',
'BB,混合组,F2,,,,,,种子1,,registered,第一分公司代表队',
].join('\n')
const fileInput = ref<HTMLInputElement | null>(null)
const picked = ref<File | null>(null)
const dragging = ref(false)
@@ -173,13 +168,17 @@ async function runImport(): Promise<void> {
}
}
function downloadTemplate(): void {
/** 中文 / 英文两套模板的表头,卡片上并列展示,让人一眼知道手上的表属于哪一套。 */
const HEADER_EN = templateHeader('en').join(' / ')
const HEADER_ZH = templateHeader('zh').join(' / ')
function downloadTemplate(language: TemplateLanguage): void {
// 带 BOM,Excel 打开才不会把中文表头识别成乱码。
const blob = new Blob([`\uFEFF${TEMPLATE_CSV}\n`], { type: 'text/csv;charset=utf-8' })
const blob = new Blob([`\uFEFF${buildTemplateCsv(language)}\n`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'entries-template.csv'
anchor.download = templateFileName(language)
anchor.rel = 'noopener'
anchor.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
@@ -193,16 +192,37 @@ function downloadTemplate(): void {
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">报名导入</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="downloadTemplate">
<MxIcon name="export" :size="14" /> 下载 CSV 模板
</button>
<div class="template-actions">
<span class="bd-note template-actions-label">下载模板</span>
<button
class="mx-btn mx-btn-primary mx-btn-sm template-button"
type="button"
title="表头为中文列名(项目编码 / 组别 / 单位编码…),给习惯看中文的填表人用;文件名 entries-template-zh.csv"
@click="downloadTemplate('zh')"
>
<MxIcon name="export" :size="14" /> 中文表头
</button>
<button
class="mx-btn mx-btn-secondary mx-btn-sm template-button"
type="button"
title="表头为英文列名(event_code / category / team_code…),给沿用老模板的填表人用;文件名 entries-template-en.csv"
@click="downloadTemplate('en')"
>
<MxIcon name="export" :size="14" /> 英文表头
</button>
</div>
</div>
<div class="mx-card-body">
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
表头中英两套都认,大小写不敏感、全角与首尾空格也会自动纠正;
<strong>中文表头</strong>为:{{ HEADER_ZH }};<strong>英文表头</strong>为:{{ HEADER_EN }}。
两套模板列数与列顺序完全一样,只有表头语言不同,按手上表格的样子下载即可;
下载按钮上的「中文表头 / 英文表头」就是文件里的表头语言。
同一列只能写一次:既有 event_code 又有「项目编码」会被拦下并提示,请删掉多余的那一列。
id_card、gender、age、heat 可缺省,
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
证件号是人员的唯一标识:先按证件号匹配;证件号为空、或库里那条还没填证件号,再按号码布匹配;
@@ -406,6 +426,19 @@ function downloadTemplate(): void {
</template>
<style scoped>
/* 两个下载按钮并排、颜色不同、文案带「中文表头 / 英文表头」:现场人多手快,不能点错。 */
.template-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.template-actions-label {
white-space: nowrap;
}
.template-button {
white-space: nowrap;
}
.drop-zone {
display: flex;
flex-direction: column;
@@ -14,9 +14,11 @@ import type {
} from '@/shared'
/**
* 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。
* 列顺序即 CSV 模板的列顺序,与服务端 handlers_config.go 的 importColumns 一致。
*
* heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。
* 勾选转 CSV(buildSelectionRows / buildTeamSelectionRows)一律用这套英文规范名;
* 下载的 CSV 模板才有中英两套表头(见 templateHeader),服务端两套都认。
*/
export const REQUIRED_COLUMNS = [
'event_code',
@@ -33,15 +35,129 @@ export const REQUIRED_COLUMNS = [
'member_names',
] as const
/** 取消勾选 / 取值的列名类型,保证别名表一列都不漏。 */
export type ImportColumnName = (typeof REQUIRED_COLUMNS)[number]
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat'] as const
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS
export const MANDATORY_COLUMNS: readonly string[] = REQUIRED_COLUMNS.filter(
const OPTIONAL_SET: readonly ImportColumnName[] = OPTIONAL_COLUMNS
export const MANDATORY_COLUMNS: readonly ImportColumnName[] = REQUIRED_COLUMNS.filter(
(column) => !OPTIONAL_SET.includes(column),
)
// ── 表头:英文规范名 + 中文别名 ──────────────────────────────────────────────
/**
* 每一列的中文别名(第一项即中文模板里的表头写法)。
*
* 与服务端 handlers_config.go 的 importColumns 逐列一致:多一个少一个别名都会造成
* 「示例数据模式能导入、连上服务端却报缺列」,两侧必须同改。
* 英文名是规范名(取值、报错、纯英文模板都用它),中文名只是给现场填表用的别名。
*/
export const COLUMN_ALIASES: Record<ImportColumnName, readonly string[]> = {
event_code: ['项目编码', '小项编码'],
category: ['组别', '组别名称'],
team_code: ['单位编码', '参赛单位编码'],
athlete_name: ['姓名', '运动员姓名'],
id_card: ['证件号', '身份证号'],
gender: ['性别'],
age: ['年龄'],
bib: ['号码布'],
seed: ['种子', '道次'],
heat: ['批次', '批次号'],
status: ['状态', '报名状态'],
member_names: ['队员名单'],
}
/**
* 表头格归一:去首尾空白、全角 ASCII 与全角空格转半角、丢掉 U+FEFF、转小写。
* 与服务端 normalizeImportHeader 同一口径——现场的表是中文 Excel 存的,
* 列名常带全角空格 / 被输入法写成全角字母 / 第一格粘着 BOM。
*/
export function normalizeHeaderCell(raw: string): string {
let folded = ''
for (const char of raw) {
const code = char.codePointAt(0) ?? 0
if (code === 0xfeff) continue
else if (code === 0x3000) folded += ' '
else if (code >= 0xff01 && code <= 0xff5e) folded += String.fromCharCode(code - 0xfee0)
else folded += char
}
return folded.trim().toLowerCase()
}
/** 认一格表头,返回规范列名;认不出的(多余列、备注列)返回 undefined 由调用方忽略。 */
export function lookupImportColumn(raw: string): ImportColumnName | undefined {
const header = normalizeHeaderCell(raw)
if (header === '') return undefined
for (const column of REQUIRED_COLUMNS) {
if (header === column) return column
if (COLUMN_ALIASES[column].some((alias) => header === alias)) return column
}
return undefined
}
/**
* 表头行 → 「规范列名 → 下标」。中英混用允许(每列各自认名字),
* 但同一列出现两种写法(既有 event_code 又有 项目编码)直接抛错:
* 两列都可能有数据,静默取其中一列等于把另一列的内容丢掉。文案与服务端逐字一致。
*/
export function importColumnIndex(header: readonly string[]): Map<ImportColumnName, number> {
const index = new Map<ImportColumnName, number>()
const spelled = new Map<ImportColumnName, string>()
header.forEach((cell, position) => {
const column = lookupImportColumn(cell)
if (column === undefined) return
const previous = spelled.get(column)
if (previous !== undefined) {
throw new Error(
`报名表表头重复:第 1 行里同一列出现了两种写法(${previous}、${cell.trim()}),` +
'请只保留一列再导入(列名中英文都可以,但不能一列写两遍)',
)
}
spelled.set(column, cell.trim())
index.set(column, position)
})
return index
}
/** 缺列提示(与服务端同一句话):中英并列,手拿中文模板的人一眼对得上自己缺的是哪一格。 */
export function missingColumnMessage(column: ImportColumnName): string {
return (
`报名表缺少列 ${column}(${COLUMN_ALIASES[column][0]}):` +
'第 1 行应是表头(列名与导入模板一致,中文与英文表头都认),' +
'请确认没有在表头上方加标题行、也没有把表头写成别的名字'
)
}
// ── CSV 模板:中英两套表头,列顺序与列数完全相同 ──────────────────────────────
export type TemplateLanguage = 'zh' | 'en'
/** 模板表头:en 用英文规范名,zh 用中文别名;两种模板逐列对应,只有表头语言不同。 */
export function templateHeader(language: TemplateLanguage): string[] {
return REQUIRED_COLUMNS.map((column) => (language === 'zh' ? COLUMN_ALIASES[column][0] : column))
}
/** 模板文件名带语言后缀,下载下来一眼分得清是中文还是英文模板。 */
export function templateFileName(language: TemplateLanguage): string {
return `entries-template-${language}.csv`
}
/** 模板里的示例行:两种语言共用,只有表头不同。 */
const TEMPLATE_ROWS: readonly string[][] = [
['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', ''],
['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', ''],
['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队'],
]
/** 模板 CSV 文本(不含 BOM,下载时由调用方补)。 */
export function buildTemplateCsv(language: TemplateLanguage): string {
return toCsv([templateHeader(language), ...TEMPLATE_ROWS.map((row) => [...row])])
}
export type SelectionMode = 'individual' | 'team'
/**
@@ -29,7 +29,12 @@ import type {
Team,
TeamInput,
} from '@/shared'
import { MANDATORY_COLUMNS } from './entrySelection'
import {
MANDATORY_COLUMNS,
importColumnIndex,
missingColumnMessage,
type ImportColumnName,
} from './entrySelection'
/**
* 基础数据页的离线示例数据源。
@@ -663,10 +668,11 @@ export class MockBaseData {
if (extension !== '.csv') return empty([XLSX_HINT])
const rows = parseCsv(await file.text())
if (rows.length < 2) throw new Error('报名表为空')
const header = (rows[0] ?? []).map((cell) => cell.replace(/^\uFEFF/, '').trim().toLowerCase())
const index = new Map(header.map((name, position) => [name, position]))
// 表头识别与服务端同一口径(英文规范名 + 中文别名、全角 / BOM / 空格容错、同一列写两遍拦下),
// 文案也逐字对齐——否则现场断网降级时提示会变样。
const index = importColumnIndex(rows[0] ?? [])
for (const column of IMPORT_COLUMNS) {
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
if (!index.has(column)) throw new Error(missingColumnMessage(column))
}
const result: EntryImportResult = {
@@ -681,7 +687,7 @@ export class MockBaseData {
rows.slice(1).forEach((row, offset) => {
const line = offset + 2
if (row.every((cell) => cell.trim() === '')) return
const cell = (name: string): string => {
const cell = (name: ImportColumnName): string => {
const position = index.get(name)
return position === undefined ? '' : (row[position] ?? '').trim()
}
@@ -1,20 +1,28 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
COLUMN_ALIASES,
MANDATORY_COLUMNS,
OPTIONAL_COLUMNS,
REQUIRED_COLUMNS,
buildSelectionRows,
buildTeamSelectionRows,
buildTemplateCsv,
buildTeamSelections,
emptyTeamPick,
entryRowLabel,
importColumnIndex,
lookupImportColumn,
missingColumnMessage,
registeredAthleteIds,
registeredTeamIds,
selectableTeamIds,
selectionAllowsSubmit,
setPickedTeams,
teamSelectionAllowsSubmit,
templateFileName,
templateHeader,
toggleMemberPick,
toggleTeamPick,
toCsv,
@@ -304,3 +312,93 @@ test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
assert.equal(entryRowLabel({ athleteName: null, memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
assert.equal(entryRowLabel({}), '')
})
// ── CSV 模板:中英两套表头,列顺序与列数完全一致 ──────────────────────────────
test('两种语言的模板表头逐列对应:列数、顺序一致,只有语言不同', () => {
const zh = templateHeader('zh')
const en = templateHeader('en')
assert.deepEqual(en, [...REQUIRED_COLUMNS], '英文模板就是英文规范名')
assert.equal(zh.length, en.length, '两种模板列数必须一样')
assert.deepEqual(zh, [
'项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单',
])
// 每一列的中文写法必须与服务端 handlers_config.go 的别名逐字一致,否则中文模板传回去会报缺列。
assert.deepEqual(
zh,
REQUIRED_COLUMNS.map((column) => COLUMN_ALIASES[column][0]),
)
assert.equal(new Set(zh).size, zh.length, '中文表头不能有两列写成同一个词')
assert.equal(new Set(en).size, en.length)
})
test('模板 CSV:只有表头语言不同,示例行与列数完全相同', () => {
const zh = buildTemplateCsv('zh').split('\n')
const en = buildTemplateCsv('en').split('\n')
assert.equal(zh.length, en.length, '两种模板行数一致')
assert.equal(zh[0], templateHeader('zh').join(','))
assert.equal(en[0], templateHeader('en').join(','))
assert.deepEqual(zh.slice(1), en.slice(1), '示例行共用,只有表头换语言')
for (const line of zh) {
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 12 列')
}
})
test('模板文件名带语言后缀,下载下来分得清中英', () => {
assert.equal(templateFileName('zh'), 'entries-template-zh.csv')
assert.equal(templateFileName('en'), 'entries-template-en.csv')
assert.notEqual(templateFileName('zh'), templateFileName('en'), '两个文件名不能重名')
})
// ── 表头识别:英文规范名与中文别名同等对待 ────────────────────────────────────
test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM / 首尾空格', () => {
assert.equal(lookupImportColumn('event_code'), 'event_code')
assert.equal(lookupImportColumn('项目编码'), 'event_code')
assert.equal(lookupImportColumn('小项编码'), 'event_code')
assert.equal(lookupImportColumn('队员名单'), 'member_names')
assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '大小写不敏感')
assert.equal(lookupImportColumn(' event_code '), 'event_code', '首尾空格')
assert.equal(lookupImportColumn('\uFEFF项目编码'), 'event_code', 'BOM 粘在第一格列名上')
assert.equal(lookupImportColumn('\u3000姓名 '), 'athlete_name', '中文输入法的全角空格')
assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '全角字母与全角下划线')
assert.equal(lookupImportColumn('报名项目'), undefined, '认不出的多余列忽略')
assert.equal(lookupImportColumn(''), undefined)
})
test('importColumnIndex 中英混用每列各自认;同一列写两遍直接拦下', () => {
const index = importColumnIndex([
'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单',
])
assert.equal(index.get('event_code'), 0)
assert.equal(index.get('category'), 1)
assert.equal(index.get('member_names'), 11, '下标按混用后的实际列序取')
assert.equal(index.size, REQUIRED_COLUMNS.length)
for (const header of [
['event_code', '项目编码', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
['event_code', 'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
]) {
assert.throws(() => importColumnIndex(header), /表头重复/, '同一列两种写法要拦下,不能静默丢一列')
}
})
test('缺列提示中英并列,且点到表头行', () => {
const message = missingColumnMessage('athlete_name')
assert.match(message, /缺少列 athlete_name(姓名)/)
assert.match(message, /第 1 行/)
assert.match(message, /中文与英文表头都认/)
})
// ImportSection 的两个下载按钮:现场人多手快,文案必须一眼能分(SFC 跑不进 node --test,
// 这里用源码契约断言接线:按钮确实在、点了确实按语言下载)。
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, /downloadTemplate\('zh'\)/)
assert.match(source, /downloadTemplate\('en'\)/)
assert.match(source, /anchor\.download = templateFileName\(language\)/, '文件名按语言取,两个文件不重名')
assert.match(source, /buildTemplateCsv\(language\)/)
assert.doesNotMatch(source, /entries-template\.csv/, '不再用不分语言的老文件名')
})
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import type { AthleteInput, ImportConflict, ImportConflictFields } from '@/shared'
import { createApiClient } from '@/shared'
import {
COLUMN_ALIASES,
REQUIRED_COLUMNS,
conflictDiffs,
decisionKeyOf,
@@ -622,3 +623,62 @@ test('MockBaseData 新增人员时证件号、号码布重复分别报错', () =
/号码布 .* 已被其他人员使用/,
)
})
// ── 示例数据模式:中文表头与服务端同口径 ──────────────────────────────────────
/** 自定义表头的报名表(csv() 固定用英文表头,这里要换语言 / 换列序)。 */
function fileWithHeader(header: readonly string[], rows: string[][], bom = false): File {
return new File([`${bom ? '\uFEFF' : ''}${toCsv([[...header], ...rows])}`], 'import.csv', { type: 'text/csv' })
}
/** 中文表头:与服务端的一列一别名逐字对应。 */
const HEADER_ZH = REQUIRED_COLUMNS.map((column) => COLUMN_ALIASES[column][0])
test('示例数据模式:中文表头照常导入(值取自中文列)', async () => {
const mock = new MockBaseData()
const result = await mock.importEntries(
fileWithHeader(HEADER_ZH, [row('中文表头甲', '110101199001010011', 'F1', '男', '3301')]),
)
assert.deepEqual([result.created, result.skipped, result.errors], [1, 0, []])
})
test('示例数据模式:带 BOM 的中文表头也认(BOM 粘在第一列列名上)', async () => {
const mock = new MockBaseData()
const result = await mock.importEntries(
fileWithHeader(HEADER_ZH, [row('中文表头乙', '110101199001010012', 'F1', '男', '3302')], true),
)
assert.deepEqual([result.created, result.skipped], [1, 0])
})
test('示例数据模式:中英混用每列各自认', async () => {
const mock = new MockBaseData()
const header = ['event_code', 'category', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单']
const result = await mock.importEntries(
fileWithHeader(header, [row('混用甲', '110101199001010013', 'F1', '男', '3303')]),
)
assert.deepEqual([result.created, result.skipped], [1, 0])
})
test('示例数据模式:老的 11 列英文表头(无 heat)仍照常导入', async () => {
const mock = new MockBaseData()
const legacy = [
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age', 'bib', 'seed', 'status', 'member_names',
]
const legacyRow = ['M100', '男子组', 'F1', '老英文表头甲', '110101199001010014', '男', '26', '3304', '', 'registered', '']
const result = await mock.importEntries(fileWithHeader(legacy, [legacyRow]))
assert.deepEqual([result.created, result.skipped], [1, 0])
})
test('示例数据模式:同一列写两遍与缺列都给出与服务端同一句话', async () => {
const mock = new MockBaseData()
const duplicated = ['event_code', '项目编码', 'category', 'team_code', 'athlete_name', '号码布', '种子', '状态', '队员名单']
await assert.rejects(
() => mock.importEntries(fileWithHeader(duplicated, [row('重复甲', '110101199001010015', 'F1')])),
/表头重复/,
)
const missingName = HEADER_ZH.filter((cell) => cell !== '姓名')
await assert.rejects(
() => mock.importEntries(fileWithHeader(missingName, [row('缺列甲', '110101199001010016', 'F1')])),
/缺少列 athlete_name(姓名)/,
)
})