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

@@ -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(姓名)/,
)
})